forked from CIT/Vmeda.Online
		
	MOBILE-3651 qtype: Implement all question types
This commit is contained in:
		
							parent
							
								
									4917067f8d
								
							
						
					
					
						commit
						916dc14401
					
				@ -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 {}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/calculated/calculated.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/calculated/calculated.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeCalculatedComponent } from './component/calculated';
 | 
			
		||||
import { AddonQtypeCalculatedHandler } from './services/handlers/calculated';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonQtypeCalculatedComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonQtypeCalculatedComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeCalculatedModule {}
 | 
			
		||||
@ -0,0 +1,75 @@
 | 
			
		||||
<ion-list class="addon-qtype-calculated-container" *ngIf="calcQuestion && (calcQuestion.text || calcQuestion.text === '')">
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="calcQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
 | 
			
		||||
    <!-- Display unit options before the answer input. -->
 | 
			
		||||
    <ng-container *ngIf="calcQuestion.options && calcQuestion.options.length && calcQuestion.optionsFirst">
 | 
			
		||||
        <ng-container *ngTemplateOutlet="radioUnits"></ng-container>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
    <ion-item *ngIf="calcQuestion.input" class="ion-text-wrap core-{{calcQuestion.input.correctIconColor}}-item">
 | 
			
		||||
        <ion-label position="stacked">{{ 'addon.mod_quiz.answercolon' | translate }}</ion-label>
 | 
			
		||||
 | 
			
		||||
        <!-- Display unit select before the answer input. -->
 | 
			
		||||
        <ng-container *ngIf="calcQuestion.select && calcQuestion.selectFirst">
 | 
			
		||||
            <ng-container *ngTemplateOutlet="selectUnits"></ng-container>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
 | 
			
		||||
        <!-- Input to enter the answer. -->
 | 
			
		||||
            <ion-input type="text" [attr.name]="calcQuestion.input.name"
 | 
			
		||||
                [placeholder]="calcQuestion.input.readOnly ? '' : 'core.question.answer' | translate"
 | 
			
		||||
                [value]="calcQuestion.input.value" [disabled]="calcQuestion.input.readOnly" autocorrect="off"
 | 
			
		||||
                [slot]="calcQuestion.select && calcQuestion.selectFirst ? 'end' : 'start'">
 | 
			
		||||
            </ion-input>
 | 
			
		||||
 | 
			
		||||
        <!-- Display unit select after the answer input. -->
 | 
			
		||||
        <ng-container *ngIf="calcQuestion.select && !calcQuestion.selectFirst">
 | 
			
		||||
            <ng-container *ngTemplateOutlet="selectUnits"></ng-container>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
 | 
			
		||||
        <ion-icon *ngIf="calcQuestion.input.correctIcon" class="core-correct-icon" slot="end"
 | 
			
		||||
            [name]="calcQuestion.input.correctIcon" [color]="[calcQuestion.input.correctIconColor]">
 | 
			
		||||
        </ion-icon>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
 | 
			
		||||
    <!-- Display unit options after the answer input. -->
 | 
			
		||||
    <ng-container *ngIf="calcQuestion.options && calcQuestion.options.length && !calcQuestion.optionsFirst">
 | 
			
		||||
        <ng-container *ngTemplateOutlet="radioUnits"></ng-container>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
</ion-list>
 | 
			
		||||
 | 
			
		||||
<!-- Template for units entered using a select. -->
 | 
			
		||||
<ng-template #selectUnits>
 | 
			
		||||
    <!-- <ion-col> -->
 | 
			
		||||
        <label *ngIf="calcQuestion!.select!.accessibilityLabel" class="accesshide" for="{{calcQuestion!.select!.id}}">
 | 
			
		||||
            {{ calcQuestion!.select!.accessibilityLabel }}
 | 
			
		||||
        </label>
 | 
			
		||||
        <ion-select id="{{calcQuestion!.select!.id}}" [name]="calcQuestion!.select!.name"
 | 
			
		||||
            [(ngModel)]="calcQuestion!.select!.selected" interface="action-sheet" [disabled]="calcQuestion!.select!.disabled"
 | 
			
		||||
            [slot]="calcQuestion?.selectFirst ? 'start' : 'end'">
 | 
			
		||||
            <ion-select-option *ngFor="let option of calcQuestion!.select!.options" [value]="option.value">
 | 
			
		||||
                {{option.label}}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
        </ion-select>
 | 
			
		||||
    <!-- </ion-col> -->
 | 
			
		||||
</ng-template>
 | 
			
		||||
 | 
			
		||||
<!-- Template for units entered using radio buttons. -->
 | 
			
		||||
<ng-template #radioUnits>
 | 
			
		||||
    <ion-radio-group [(ngModel)]="calcQuestion!.unit" [name]="calcQuestion!.optionsName">
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngFor="let option of calcQuestion!.options">
 | 
			
		||||
            <ion-label>{{ option.text }}</ion-label>
 | 
			
		||||
            <ion-radio slot="end" [value]="option.value" [disabled]="option.disabled || calcQuestion!.input?.readOnly"
 | 
			
		||||
                [color]="calcQuestion!.input?.correctIconColor">
 | 
			
		||||
            </ion-radio>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
 | 
			
		||||
        <!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
 | 
			
		||||
        <input type="hidden" [ngModel]="calcQuestion!.unit" [attr.name]="calcQuestion!.optionsName">
 | 
			
		||||
    </ion-radio-group>
 | 
			
		||||
</ng-template>
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/calculated/component/calculated.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/calculated/component/calculated.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										237
									
								
								src/addons/qtype/calculated/services/handlers/calculated.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										237
									
								
								src/addons/qtype/calculated/services/handlers/calculated.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,237 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeCalculatedComponent } from '../../component/calculated';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support calculated question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeCalculatedHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    static readonly UNITINPUT = '0';
 | 
			
		||||
    static readonly UNITRADIO = '1';
 | 
			
		||||
    static readonly UNITSELECT = '2';
 | 
			
		||||
    static readonly UNITNONE = '3';
 | 
			
		||||
 | 
			
		||||
    static readonly UNITGRADED = '1';
 | 
			
		||||
    static readonly UNITOPTIONAL = '0';
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeCalculated';
 | 
			
		||||
    type = 'qtype_calculated';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeCalculatedComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the units are in a separate field for the question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @return Whether units are in a separate field.
 | 
			
		||||
     */
 | 
			
		||||
    hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean {
 | 
			
		||||
        if (!question.parsedSettings) {
 | 
			
		||||
            const element = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
 | 
			
		||||
            return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITRADIO ||
 | 
			
		||||
            question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITSELECT;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        if (!this.isGradableResponse(question, answers, component, componentId)) {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { answer, unit } = this.parseAnswer(question, <string> answers.answer);
 | 
			
		||||
        if (answer === null) {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!question.parsedSettings) {
 | 
			
		||||
            if (this.hasSeparateUnitField(question)) {
 | 
			
		||||
                return this.isValidValue(<string> answers.unit) ? 1 : 0;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // We cannot know if the answer should contain units or not.
 | 
			
		||||
            return -1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (question.parsedSettings.unitdisplay != AddonQtypeCalculatedHandlerService.UNITINPUT && unit) {
 | 
			
		||||
            // There should be no units or be outside of the input, not valid.
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.hasSeparateUnitField(question) && !this.isValidValue(<string> answers.unit)) {
 | 
			
		||||
            // Unit not supplied as a separate field and it's required.
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (question.parsedSettings.unitdisplay == AddonQtypeCalculatedHandlerService.UNITINPUT &&
 | 
			
		||||
                question.parsedSettings.unitgradingtype == AddonQtypeCalculatedHandlerService.UNITGRADED &&
 | 
			
		||||
                !this.isValidValue(unit)) {
 | 
			
		||||
            // Unit not supplied inside the input and it's required.
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): number {
 | 
			
		||||
        return this.isValidValue(<string> answers.answer) ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
 | 
			
		||||
            CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a value is valid (not empty).
 | 
			
		||||
     *
 | 
			
		||||
     * @param value Value to check.
 | 
			
		||||
     * @return Whether the value is valid.
 | 
			
		||||
     */
 | 
			
		||||
    isValidValue(value: string | number | null): boolean {
 | 
			
		||||
        return !!value || value === '0' || value === 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse an answer string.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answer Answer.
 | 
			
		||||
     * @return Answer and unit.
 | 
			
		||||
     */
 | 
			
		||||
    parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } {
 | 
			
		||||
        if (!answer) {
 | 
			
		||||
            return { answer: null, unit: null };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
 | 
			
		||||
 | 
			
		||||
        // Strip spaces (which may be thousands separators) and change other forms of writing e to e.
 | 
			
		||||
        answer = answer.replace(/ /g, '');
 | 
			
		||||
        answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');
 | 
			
		||||
 | 
			
		||||
        // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
 | 
			
		||||
        // Else assume it is a decimal separator, and change it to '.'.
 | 
			
		||||
        if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
 | 
			
		||||
            answer = answer.replace(',', '');
 | 
			
		||||
        } else {
 | 
			
		||||
            answer = answer.replace(',', '.');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let unitsLeft = false;
 | 
			
		||||
        let match: RegExpMatchArray | null = null;
 | 
			
		||||
 | 
			
		||||
        if (!question.parsedSettings || question.parsedSettings.unitsleft === null) {
 | 
			
		||||
            // We don't know if units should be before or after so we check both.
 | 
			
		||||
            match = answer.match(new RegExp('^' + regexString));
 | 
			
		||||
            if (!match) {
 | 
			
		||||
                unitsLeft = true;
 | 
			
		||||
                match = answer.match(new RegExp(regexString + '$'));
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            unitsLeft = question.parsedSettings.unitsleft == '1';
 | 
			
		||||
            regexString = unitsLeft ? regexString + '$' : '^' + regexString;
 | 
			
		||||
 | 
			
		||||
            match = answer.match(new RegExp(regexString));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!match) {
 | 
			
		||||
            return { answer: null, unit: null };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const numberString = match[0];
 | 
			
		||||
        const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length);
 | 
			
		||||
 | 
			
		||||
        // No need to calculate the multiplier.
 | 
			
		||||
        return { answer: Number(numberString), unit };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeCalculatedHandler extends makeSingleton(AddonQtypeCalculatedHandlerService) {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/qtype/calculatedmulti/calculatedmulti.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/qtype/calculatedmulti/calculatedmulti.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeCalculatedMultiHandler } from './services/handlers/calculatedmulti';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedMultiHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeCalculatedMultiModule {}
 | 
			
		||||
@ -0,0 +1,109 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeMultichoiceHandler } from '@addons/qtype/multichoice/services/handlers/multichoice';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support calculated multi question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeCalculatedMultiHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeCalculatedMulti';
 | 
			
		||||
    type = 'qtype_calculatedmulti';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        // Calculated multi behaves like a multichoice, use the same component.
 | 
			
		||||
        return AddonQtypeMultichoiceComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // This question type depends on multichoice.
 | 
			
		||||
        return AddonQtypeMultichoiceHandler.instance.isCompleteResponseSingle(answers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // This question type depends on multichoice.
 | 
			
		||||
        return AddonQtypeMultichoiceHandler.instance.isGradableResponseSingle(answers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        // This question type depends on multichoice.
 | 
			
		||||
        return AddonQtypeMultichoiceHandler.instance.isSameResponseSingle(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeCalculatedMultiHandler extends makeSingleton(AddonQtypeCalculatedMultiHandlerService) {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/qtype/calculatedsimple/calculatedsimple.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/qtype/calculatedsimple/calculatedsimple.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeCalculatedSimpleHandler } from './services/handlers/calculatedsimple';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedSimpleHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeCalculatedSimpleModule {}
 | 
			
		||||
@ -0,0 +1,115 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonQtypeCalculatedComponent } from '@addons/qtype/calculated/component/calculated';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeCalculatedHandler } from '@addons/qtype/calculated/services/handlers/calculated';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support calculated simple question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeCalculatedSimpleHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeCalculatedSimple';
 | 
			
		||||
    type = 'qtype_calculatedsimple';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        // Calculated simple behaves like a calculated, use the same component.
 | 
			
		||||
        return AddonQtypeCalculatedComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // This question type depends on calculated.
 | 
			
		||||
        return AddonQtypeCalculatedHandler.instance.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // This question type depends on calculated.
 | 
			
		||||
        return AddonQtypeCalculatedHandler.instance.isGradableResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        // This question type depends on calculated.
 | 
			
		||||
        return AddonQtypeCalculatedHandler.instance.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeCalculatedSimpleHandler extends makeSingleton(AddonQtypeCalculatedSimpleHandlerService) {}
 | 
			
		||||
							
								
								
									
										848
									
								
								src/addons/qtype/ddimageortext/classes/ddimageortext.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										848
									
								
								src/addons/qtype/ddimageortext/classes/ddimageortext.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,848 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { AddonModQuizDdImageOrTextQuestionData } from '../component/ddimageortext';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class to make a question of ddimageortext type work.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdImageOrTextQuestion {
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
    protected toLoad = 0;
 | 
			
		||||
    protected doc!: AddonQtypeDdImageOrTextQuestionDocStructure;
 | 
			
		||||
    protected afterImageLoadDone = false;
 | 
			
		||||
    protected proportion = 1;
 | 
			
		||||
    protected selected?: HTMLElement | null; // Selected element (being "dragged").
 | 
			
		||||
    protected resizeFunction?: (ev?: Event) => void;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create the this.
 | 
			
		||||
     *
 | 
			
		||||
     * @param container The container HTMLElement of the question.
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param readOnly Whether it's read only.
 | 
			
		||||
     * @param drops The drop zones received in the init object of the question.
 | 
			
		||||
     */
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected container: HTMLElement,
 | 
			
		||||
        protected question: AddonModQuizDdImageOrTextQuestionData,
 | 
			
		||||
        protected readOnly: boolean,
 | 
			
		||||
        protected drops?: unknown[],
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestion');
 | 
			
		||||
 | 
			
		||||
        this.initializer();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Calculate image proportion to make easy conversions.
 | 
			
		||||
     */
 | 
			
		||||
    calculateImgProportion(): void {
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Render the position related to the current image dimensions.
 | 
			
		||||
        this.proportion = 1;
 | 
			
		||||
        if (bgImg.width != bgImg.naturalWidth) {
 | 
			
		||||
            this.proportion = bgImg.width / bgImg.naturalWidth;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert the X and Y position of the BG IMG to a position relative to the window.
 | 
			
		||||
     *
 | 
			
		||||
     * @param bgImgXY X and Y of the BG IMG relative position.
 | 
			
		||||
     * @return Position relative to the window.
 | 
			
		||||
     */
 | 
			
		||||
    convertToWindowXY(bgImgXY: number[]): number[] {
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return bgImgXY;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
 | 
			
		||||
 | 
			
		||||
        // Render the position related to the current image dimensions.
 | 
			
		||||
        bgImgXY[0] *= this.proportion;
 | 
			
		||||
        bgImgXY[1] *= this.proportion;
 | 
			
		||||
 | 
			
		||||
        return [Number(bgImgXY[0]) + position[0] + 1, Number(bgImgXY[1]) + position[1] + 1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create and initialize all draggable elements and drop zones.
 | 
			
		||||
     */
 | 
			
		||||
    async createAllDragAndDrops(): Promise<void> {
 | 
			
		||||
        // Initialize drop zones.
 | 
			
		||||
        this.initDrops();
 | 
			
		||||
 | 
			
		||||
        // Initialize drag items area.
 | 
			
		||||
        this.doc.dragItemsArea?.classList.add('clearfix');
 | 
			
		||||
        this.makeDragAreaClickable();
 | 
			
		||||
 | 
			
		||||
        const dragItemHomes = this.doc.dragItemHomes();
 | 
			
		||||
        let i = 0;
 | 
			
		||||
 | 
			
		||||
        // Create the draggable items.
 | 
			
		||||
        for (let x = 0; x < dragItemHomes.length; x++) {
 | 
			
		||||
 | 
			
		||||
            const dragItemHome = dragItemHomes[x];
 | 
			
		||||
            const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes') ?? -1;
 | 
			
		||||
            const choice = this.doc.getClassnameNumericSuffix(dragItemHome, 'choice') ?? -1;
 | 
			
		||||
            const group = this.doc.getClassnameNumericSuffix(dragItemHome, 'group') ?? -1;
 | 
			
		||||
 | 
			
		||||
            // Images need to be inside a div element to admit padding with width and height.
 | 
			
		||||
            if (dragItemHome.tagName == 'IMG') {
 | 
			
		||||
                const wrap = document.createElement('div');
 | 
			
		||||
                wrap.className = dragItemHome.className;
 | 
			
		||||
                dragItemHome.className = '';
 | 
			
		||||
 | 
			
		||||
                // Insert wrapper before the image in the DOM tree.
 | 
			
		||||
                dragItemHome.parentNode?.insertBefore(wrap, dragItemHome);
 | 
			
		||||
                // Move the image into wrapper.
 | 
			
		||||
                wrap.appendChild(dragItemHome);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Create a new drag item for this home.
 | 
			
		||||
            const dragNode = this.doc.cloneNewDragItem(i, dragItemNo);
 | 
			
		||||
            i++;
 | 
			
		||||
 | 
			
		||||
            // Make the item draggable.
 | 
			
		||||
            this.draggableForQuestion(dragNode, group, choice);
 | 
			
		||||
 | 
			
		||||
            // If the draggable item needs to be created more than once, create the rest of copies.
 | 
			
		||||
            if (dragNode?.classList.contains('infinite')) {
 | 
			
		||||
                const groupSize = this.doc.dropZoneGroup(group).length;
 | 
			
		||||
                let dragsToCreate = groupSize - 1;
 | 
			
		||||
 | 
			
		||||
                while (dragsToCreate > 0) {
 | 
			
		||||
                    const newDragNode = this.doc.cloneNewDragItem(i, dragItemNo);
 | 
			
		||||
                    i++;
 | 
			
		||||
                    this.draggableForQuestion(newDragNode, group, choice);
 | 
			
		||||
 | 
			
		||||
                    dragsToCreate--;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.nextTick();
 | 
			
		||||
 | 
			
		||||
        // All drag items have been created, position them.
 | 
			
		||||
        this.repositionDragsForQuestion();
 | 
			
		||||
 | 
			
		||||
        if (!this.readOnly) {
 | 
			
		||||
            const dropZones = this.doc.dropZones();
 | 
			
		||||
            dropZones.forEach((dropZone) => {
 | 
			
		||||
                dropZone.setAttribute('tabIndex', '0');
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Deselect all drags.
 | 
			
		||||
     */
 | 
			
		||||
    deselectDrags(): void {
 | 
			
		||||
        const drags = this.doc.dragItems();
 | 
			
		||||
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            drag.classList.remove('beingdragged');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.selected = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function to call when the instance is no longer needed.
 | 
			
		||||
     */
 | 
			
		||||
    destroy(): void {
 | 
			
		||||
        this.stopPolling();
 | 
			
		||||
 | 
			
		||||
        if (this.resizeFunction) {
 | 
			
		||||
            window.removeEventListener('resize', this.resizeFunction);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make an element draggable.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element to make draggable.
 | 
			
		||||
     * @param group Group the element belongs to.
 | 
			
		||||
     * @param choice Choice the element belongs to.
 | 
			
		||||
     */
 | 
			
		||||
    draggableForQuestion(drag: HTMLElement | null, group: number, choice: number): void {
 | 
			
		||||
        if (!drag) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set attributes.
 | 
			
		||||
        drag.setAttribute('group', String(group));
 | 
			
		||||
        drag.setAttribute('choice', String(choice));
 | 
			
		||||
 | 
			
		||||
        if (!this.readOnly) {
 | 
			
		||||
            // Listen to click events.
 | 
			
		||||
            drag.addEventListener('click', (e) => {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
                if (drag.classList.contains('beingdragged')) {
 | 
			
		||||
                    this.deselectDrags();
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.selectDrag(drag);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function called when a drop zone is clicked.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dropNode Drop element.
 | 
			
		||||
     */
 | 
			
		||||
    dropClick(dropNode: HTMLElement): void {
 | 
			
		||||
        const drag = this.selected;
 | 
			
		||||
        if (!drag) {
 | 
			
		||||
            // No selected item, nothing to do.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Deselect the drag and place it in the position of this drop zone if it belongs to the same group.
 | 
			
		||||
        this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
        if (Number(dropNode.getAttribute('group')) === Number(drag.getAttribute('group'))) {
 | 
			
		||||
            this.placeDragInDrop(drag, dropNode);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all the draggable elements for a choice and a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choice Choice number.
 | 
			
		||||
     * @param drop Drop zone.
 | 
			
		||||
     * @return Draggable elements.
 | 
			
		||||
     */
 | 
			
		||||
    getChoicesForDrop(choice: number, drop: HTMLElement): HTMLElement[] {
 | 
			
		||||
        if (!this.doc.topNode) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Array.from(
 | 
			
		||||
            this.doc.topNode.querySelectorAll('div.dragitemgroup' + drop.getAttribute('group') + ` .choice${choice}.drag`),
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an unplaced draggable element that belongs to a certain choice and drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choice Choice number.
 | 
			
		||||
     * @param drop Drop zone.
 | 
			
		||||
     * @return Unplaced draggable element.
 | 
			
		||||
     */
 | 
			
		||||
    getUnplacedChoiceForDrop(choice: number, drop: HTMLElement): HTMLElement | null {
 | 
			
		||||
        const dragItems = this.getChoicesForDrop(choice, drop);
 | 
			
		||||
 | 
			
		||||
        const foundItem = dragItems.find((dragItem) =>
 | 
			
		||||
            !dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged'));
 | 
			
		||||
 | 
			
		||||
        return foundItem || null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize drop zones.
 | 
			
		||||
     */
 | 
			
		||||
    initDrops(): void {
 | 
			
		||||
        const dropAreas = this.doc.topNode?.querySelector('div.dropzones');
 | 
			
		||||
        if (!dropAreas) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const groupNodes: Record<number, HTMLElement> = {};
 | 
			
		||||
 | 
			
		||||
        // Create all group nodes and add them to the drop area.
 | 
			
		||||
        for (let groupNo = 1; groupNo <= 8; groupNo++) {
 | 
			
		||||
            const groupNode = document.createElement('div');
 | 
			
		||||
            groupNode.className = `dropzonegroup${groupNo}`;
 | 
			
		||||
 | 
			
		||||
            dropAreas.appendChild(groupNode);
 | 
			
		||||
            groupNodes[groupNo] = groupNode;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create the drops specified by the init object.
 | 
			
		||||
        for (const dropNo in this.drops) {
 | 
			
		||||
            const drop = this.drops[dropNo];
 | 
			
		||||
            const nodeClass = `dropzone group${drop.group} place${dropNo}`;
 | 
			
		||||
            const title = drop.text.replace('"', '"');
 | 
			
		||||
            const dropNode = document.createElement('div');
 | 
			
		||||
 | 
			
		||||
            dropNode.setAttribute('title', title);
 | 
			
		||||
            dropNode.className = nodeClass;
 | 
			
		||||
 | 
			
		||||
            groupNodes[drop.group].appendChild(dropNode);
 | 
			
		||||
            dropNode.style.opacity = '0.5';
 | 
			
		||||
            dropNode.setAttribute('xy', drop.xy);
 | 
			
		||||
            dropNode.setAttribute('aria-label', drop.text);
 | 
			
		||||
            dropNode.setAttribute('place', dropNo);
 | 
			
		||||
            dropNode.setAttribute('inputid', drop.fieldname.replace(':', '_'));
 | 
			
		||||
            dropNode.setAttribute('group', drop.group);
 | 
			
		||||
 | 
			
		||||
            dropNode.addEventListener('click', (e) => {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
                this.dropClick(dropNode);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     */
 | 
			
		||||
    initializer(): void {
 | 
			
		||||
        this.doc = new AddonQtypeDdImageOrTextQuestionDocStructure(this.container, this.question.slot);
 | 
			
		||||
 | 
			
		||||
        if (this.readOnly) {
 | 
			
		||||
            this.doc.topNode?.classList.add('readonly');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Wait the DOM to be rendered.
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            const bgImg = this.doc.bgImg();
 | 
			
		||||
            if (!bgImg) {
 | 
			
		||||
                this.logger.error('Background image not found');
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Wait for background image to be loaded.
 | 
			
		||||
            // On iOS, complete is mistakenly true, check also naturalWidth for compatibility.
 | 
			
		||||
            if (!bgImg.complete || !bgImg.naturalWidth) {
 | 
			
		||||
                this.toLoad++;
 | 
			
		||||
                bgImg.addEventListener('load', () => {
 | 
			
		||||
                    this.toLoad--;
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const itemHomes = this.doc.dragItemHomes();
 | 
			
		||||
            itemHomes.forEach((item) => {
 | 
			
		||||
                if (item.tagName != 'IMG') {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
                // Wait for drag images to be loaded.
 | 
			
		||||
                // On iOS, complete is mistakenly true, check also naturalWidth for compatibility.
 | 
			
		||||
                const itemImg = <HTMLImageElement> item;
 | 
			
		||||
 | 
			
		||||
                if (!itemImg.complete || !itemImg.naturalWidth) {
 | 
			
		||||
                    this.toLoad++;
 | 
			
		||||
                    itemImg.addEventListener('load', () => {
 | 
			
		||||
                        this.toLoad--;
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.pollForImageLoad();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.resizeFunction = this.repositionDragsForQuestion.bind(this);
 | 
			
		||||
        window.addEventListener('resize', this.resizeFunction!);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make the drag items area clickable.
 | 
			
		||||
     */
 | 
			
		||||
    makeDragAreaClickable(): void {
 | 
			
		||||
        if (this.readOnly) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const home = this.doc.dragItemsArea;
 | 
			
		||||
        home?.addEventListener('click', (e) => {
 | 
			
		||||
            const drag = this.selected;
 | 
			
		||||
            if (!drag) {
 | 
			
		||||
                // No element selected, nothing to do.
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // An element was selected. Deselect it and move it back to the area if needed.
 | 
			
		||||
            this.deselectDrags();
 | 
			
		||||
            this.removeDragFromDrop(drag);
 | 
			
		||||
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Place a draggable element into a certain drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Draggable element.
 | 
			
		||||
     * @param drop Drop zone element.
 | 
			
		||||
     */
 | 
			
		||||
    placeDragInDrop(drag: HTMLElement, drop: HTMLElement): void {
 | 
			
		||||
        // Search the input related to the drop zone.
 | 
			
		||||
        const targetInputId = drop.getAttribute('inputid') || '';
 | 
			
		||||
        const inputNode = this.doc.topNode?.querySelector<HTMLInputElement>(`input#${targetInputId}`);
 | 
			
		||||
 | 
			
		||||
        // Check if the draggable item is already assigned to an input and if it's the same as the one of the drop zone.
 | 
			
		||||
        const originInputId = drag.getAttribute('inputid');
 | 
			
		||||
        if (originInputId && originInputId != targetInputId) {
 | 
			
		||||
            // Remove it from the previous place.
 | 
			
		||||
            const originInputNode = this.doc.topNode?.querySelector<HTMLInputElement>(`input#${originInputId}`);
 | 
			
		||||
            originInputNode?.setAttribute('value', '0');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Now position the draggable and set it to the input.
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(drop, undefined, 'ddarea');
 | 
			
		||||
        const choice = drag.getAttribute('choice');
 | 
			
		||||
        drag.style.left = position[0] - 1 + 'px';
 | 
			
		||||
        drag.style.top = position[1] - 1 + 'px';
 | 
			
		||||
        drag.classList.add('placed');
 | 
			
		||||
 | 
			
		||||
        if (choice) {
 | 
			
		||||
            inputNode?.setAttribute('value', choice);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        drag.setAttribute('inputid', targetInputId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Wait for images to be loaded.
 | 
			
		||||
     */
 | 
			
		||||
    pollForImageLoad(): void {
 | 
			
		||||
        if (this.afterImageLoadDone) {
 | 
			
		||||
            // Already done, stop.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.toLoad <= 0) {
 | 
			
		||||
            // All images loaded.
 | 
			
		||||
            this.createAllDragAndDrops();
 | 
			
		||||
            this.afterImageLoadDone = true;
 | 
			
		||||
            this.question.loaded = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Try again after a while.
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            this.pollForImageLoad();
 | 
			
		||||
        }, 1000);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a draggable element from the drop zone where it is.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Draggable element to remove.
 | 
			
		||||
     */
 | 
			
		||||
    removeDragFromDrop(drag: HTMLElement): void {
 | 
			
		||||
        // Check if the draggable element is assigned to an input. If so, empty the input's value.
 | 
			
		||||
        const inputId = drag.getAttribute('inputid');
 | 
			
		||||
        if (inputId) {
 | 
			
		||||
            this.doc.topNode?.querySelector<HTMLInputElement>(`input#${inputId}`)?.setAttribute('value', '0');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Move the element to its original position.
 | 
			
		||||
        const dragItemHome = this.doc.dragItemHome(Number(drag.getAttribute('dragitemno')));
 | 
			
		||||
        if (!dragItemHome) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, 'ddarea');
 | 
			
		||||
        drag.style.left = position[0] + 'px';
 | 
			
		||||
        drag.style.top = position[1] + 'px';
 | 
			
		||||
        drag.classList.remove('placed');
 | 
			
		||||
 | 
			
		||||
        drag.setAttribute('inputid', '');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reposition all the draggable elements and drop zones.
 | 
			
		||||
     */
 | 
			
		||||
    repositionDragsForQuestion(): void {
 | 
			
		||||
        const dragItems = this.doc.dragItems();
 | 
			
		||||
 | 
			
		||||
        // Mark all draggable items as "unplaced", they will be placed again later.
 | 
			
		||||
        dragItems.forEach((dragItem) => {
 | 
			
		||||
            dragItem.classList.remove('placed');
 | 
			
		||||
            dragItem.setAttribute('inputid', '');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Calculate the proportion to apply to images.
 | 
			
		||||
        this.calculateImgProportion();
 | 
			
		||||
 | 
			
		||||
        // Apply the proportion to all images in drag item homes.
 | 
			
		||||
        const dragItemHomes = this.doc.dragItemHomes();
 | 
			
		||||
        for (let x = 0; x < dragItemHomes.length; x++) {
 | 
			
		||||
            const dragItemHome = dragItemHomes[x];
 | 
			
		||||
            const dragItemHomeImg = dragItemHome.querySelector('img');
 | 
			
		||||
 | 
			
		||||
            if (!dragItemHomeImg || dragItemHomeImg.naturalWidth <= 0) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const widthHeight = [Math.round(dragItemHomeImg.naturalWidth * this.proportion),
 | 
			
		||||
                Math.round(dragItemHomeImg.naturalHeight * this.proportion)];
 | 
			
		||||
 | 
			
		||||
            dragItemHomeImg.style.width = widthHeight[0] + 'px';
 | 
			
		||||
            dragItemHomeImg.style.height = widthHeight[1] + 'px';
 | 
			
		||||
 | 
			
		||||
            // Apply the proportion to all the images cloned from this home.
 | 
			
		||||
            const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes');
 | 
			
		||||
            const groupNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'group');
 | 
			
		||||
            const dragsImg = this.doc.topNode ?
 | 
			
		||||
                Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.drag.group${groupNo}.dragitems${dragItemNo} img`)) : [];
 | 
			
		||||
 | 
			
		||||
            dragsImg.forEach((dragImg) => {
 | 
			
		||||
                dragImg.style.width = widthHeight[0] + 'px';
 | 
			
		||||
                dragImg.style.height = widthHeight[1] + 'px';
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Update the padding of all draggable elements.
 | 
			
		||||
        this.updatePaddingSizesAll();
 | 
			
		||||
 | 
			
		||||
        const dropZones = this.doc.dropZones();
 | 
			
		||||
        for (let x = 0; x < dropZones.length; x++) {
 | 
			
		||||
            // Re-position the drop zone based on the proportion.
 | 
			
		||||
            const dropZone = dropZones[x];
 | 
			
		||||
            const dropZoneXY = dropZone.getAttribute('xy')?.split(',').map((i) => Number(i));
 | 
			
		||||
            const relativeXY = this.convertToWindowXY(dropZoneXY || []);
 | 
			
		||||
 | 
			
		||||
            dropZone.style.left = relativeXY[0] + 'px';
 | 
			
		||||
            dropZone.style.top = relativeXY[1] + 'px';
 | 
			
		||||
 | 
			
		||||
            // Re-place items got from the inputs.
 | 
			
		||||
            const inputCss = 'input#' + dropZone.getAttribute('inputid');
 | 
			
		||||
            const input = this.doc.topNode?.querySelector<HTMLInputElement>(inputCss);
 | 
			
		||||
            const choice = input ? Number(input.value) : -1;
 | 
			
		||||
 | 
			
		||||
            if (choice > 0) {
 | 
			
		||||
                const dragItem = this.getUnplacedChoiceForDrop(choice, dropZone);
 | 
			
		||||
 | 
			
		||||
                if (dragItem !== null) {
 | 
			
		||||
                    this.placeDragInDrop(dragItem, dropZone);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Re-place draggable items not placed drop zones (they will be placed in the original position).
 | 
			
		||||
        for (let x = 0; x < dragItems.length; x++) {
 | 
			
		||||
            const dragItem = dragItems[x];
 | 
			
		||||
            if (!dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged')) {
 | 
			
		||||
                this.removeDragFromDrop(dragItem);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Mark a draggable element as selected.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element to select.
 | 
			
		||||
     */
 | 
			
		||||
    selectDrag(drag: HTMLElement): void {
 | 
			
		||||
        // Deselect previous ones.
 | 
			
		||||
        this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
        this.selected = drag;
 | 
			
		||||
        drag.classList.add('beingdragged');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Stop waiting for images to be loaded.
 | 
			
		||||
     */
 | 
			
		||||
    stopPolling(): void {
 | 
			
		||||
        this.afterImageLoadDone = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the padding of all items in a group to make them all have the same width and height.
 | 
			
		||||
     *
 | 
			
		||||
     * @param groupNo The group number.
 | 
			
		||||
     */
 | 
			
		||||
    updatePaddingSizeForGroup(groupNo: number): void {
 | 
			
		||||
 | 
			
		||||
        // Get all the items for this group.
 | 
			
		||||
        const groupItems = this.doc.topNode ?
 | 
			
		||||
            Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.draghome.group${groupNo}`)) : [];
 | 
			
		||||
 | 
			
		||||
        if (groupItems.length == 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the max width and height of the items.
 | 
			
		||||
        let maxWidth = 0;
 | 
			
		||||
        let maxHeight = 0;
 | 
			
		||||
 | 
			
		||||
        for (let x = 0; x < groupItems.length; x++) {
 | 
			
		||||
            // Check if the item has an img.
 | 
			
		||||
            const item = groupItems[x];
 | 
			
		||||
            const img = item.querySelector('img');
 | 
			
		||||
 | 
			
		||||
            if (img) {
 | 
			
		||||
                maxWidth = Math.max(maxWidth, Math.round(this.proportion * img.naturalWidth));
 | 
			
		||||
                maxHeight = Math.max(maxHeight, Math.round(this.proportion * img.naturalHeight));
 | 
			
		||||
            } else {
 | 
			
		||||
                // Remove the padding to calculate the size.
 | 
			
		||||
                const originalPadding = item.style.padding;
 | 
			
		||||
                item.style.padding = '';
 | 
			
		||||
 | 
			
		||||
                // Text is not affected by the proportion.
 | 
			
		||||
                maxWidth = Math.max(maxWidth, Math.round(item.clientWidth));
 | 
			
		||||
                maxHeight = Math.max(maxHeight, Math.round(item.clientHeight));
 | 
			
		||||
 | 
			
		||||
                // Restore the padding.
 | 
			
		||||
                item.style.padding = originalPadding;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (maxWidth <= 0 || maxHeight <= 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add a variable padding to the image or text.
 | 
			
		||||
        maxWidth = Math.round(maxWidth + this.proportion * 8);
 | 
			
		||||
        maxHeight = Math.round(maxHeight + this.proportion * 8);
 | 
			
		||||
 | 
			
		||||
        for (let x = 0; x < groupItems.length; x++) {
 | 
			
		||||
            // Check if the item has an img and calculate its width and height.
 | 
			
		||||
            const item = groupItems[x];
 | 
			
		||||
            const img = item.querySelector('img');
 | 
			
		||||
            let width: number | undefined;
 | 
			
		||||
            let height: number | undefined;
 | 
			
		||||
 | 
			
		||||
            if (img) {
 | 
			
		||||
                width = Math.round(img.naturalWidth * this.proportion);
 | 
			
		||||
                height = Math.round(img.naturalHeight * this.proportion);
 | 
			
		||||
            } else {
 | 
			
		||||
                // Remove the padding to calculate the size.
 | 
			
		||||
                const originalPadding = item.style.padding;
 | 
			
		||||
                item.style.padding = '';
 | 
			
		||||
 | 
			
		||||
                // Text is not affected by the proportion.
 | 
			
		||||
                width = Math.round(item.clientWidth);
 | 
			
		||||
                height = Math.round(item.clientHeight);
 | 
			
		||||
 | 
			
		||||
                // Restore the padding.
 | 
			
		||||
                item.style.padding = originalPadding;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Now set the right padding to make this item have the max height and width.
 | 
			
		||||
            const marginTopBottom = Math.round((maxHeight - height) / 2);
 | 
			
		||||
            const marginLeftRight = Math.round((maxWidth - width) / 2);
 | 
			
		||||
 | 
			
		||||
            // Correction for the roundings.
 | 
			
		||||
            const widthCorrection = maxWidth - (width + marginLeftRight * 2);
 | 
			
		||||
            const heightCorrection = maxHeight - (height + marginTopBottom * 2);
 | 
			
		||||
 | 
			
		||||
            item.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' +
 | 
			
		||||
                (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px';
 | 
			
		||||
 | 
			
		||||
            const dragItemNo = this.doc.getClassnameNumericSuffix(item, 'dragitemhomes');
 | 
			
		||||
            const drags = this.doc.topNode ?
 | 
			
		||||
                Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.drag.group${groupNo}.dragitems${dragItemNo}`)) : [];
 | 
			
		||||
 | 
			
		||||
            drags.forEach((drag) => {
 | 
			
		||||
                drag.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' +
 | 
			
		||||
                        (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px';
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // It adds the border of 1px to the width.
 | 
			
		||||
        const zoneGroups = this.doc.dropZoneGroup(groupNo);
 | 
			
		||||
        zoneGroups.forEach((zone) => {
 | 
			
		||||
            zone.style.width = maxWidth + 2 + 'px ';
 | 
			
		||||
            zone.style.height = maxHeight + 2 + 'px ';
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the padding of all items in all groups.
 | 
			
		||||
     */
 | 
			
		||||
    updatePaddingSizesAll(): void {
 | 
			
		||||
        for (let groupNo = 1; groupNo <= 8; groupNo++) {
 | 
			
		||||
            this.updatePaddingSizeForGroup(groupNo);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encapsulates operations on dd area.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdImageOrTextQuestionDocStructure {
 | 
			
		||||
 | 
			
		||||
    topNode: HTMLElement | null;
 | 
			
		||||
    dragItemsArea: HTMLElement | null;
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected container: HTMLElement,
 | 
			
		||||
        protected slot: number,
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestionDocStructure');
 | 
			
		||||
        this.topNode = this.container.querySelector<HTMLElement>('.addon-qtype-ddimageortext-container');
 | 
			
		||||
        this.dragItemsArea = this.topNode?.querySelector<HTMLElement>('div.draghomes') || null;
 | 
			
		||||
 | 
			
		||||
        if (this.dragItemsArea) {
 | 
			
		||||
            // On 3.9+ dragitems were removed.
 | 
			
		||||
            const dragItems = this.topNode!.querySelector('div.dragitems');
 | 
			
		||||
 | 
			
		||||
            if (dragItems) {
 | 
			
		||||
                // Remove empty div.dragitems.
 | 
			
		||||
                dragItems.remove();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 3.6+ site, transform HTML so it has the same structure as in Moodle 3.5.
 | 
			
		||||
            const ddArea = this.topNode!.querySelector('div.ddarea');
 | 
			
		||||
            if (ddArea) {
 | 
			
		||||
                // Move div.dropzones to div.ddarea.
 | 
			
		||||
                const dropZones = this.topNode!.querySelector('div.dropzones');
 | 
			
		||||
                if (dropZones) {
 | 
			
		||||
                    ddArea.appendChild(dropZones);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Move div.draghomes to div.ddarea and rename the class to .dragitems.
 | 
			
		||||
                ddArea?.appendChild(this.dragItemsArea);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.dragItemsArea.classList.remove('draghomes');
 | 
			
		||||
            this.dragItemsArea.classList.add('dragitems');
 | 
			
		||||
 | 
			
		||||
            // Add .dragitemhomesNNN class to drag items.
 | 
			
		||||
            Array.from(this.dragItemsArea.querySelectorAll('.draghome')).forEach((draghome, index) => {
 | 
			
		||||
                draghome.classList.add(`dragitemhomes${index}`);
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            this.dragItemsArea = this.topNode!.querySelector<HTMLElement>('div.dragitems');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    querySelector<T = HTMLElement>(element: HTMLElement | null, selector: string): T | null {
 | 
			
		||||
        if (!element) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return <T | null> element.querySelector(selector);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] {
 | 
			
		||||
        if (!element) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Array.from(element.querySelectorAll(selector));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItems(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, '.drag');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dropZones(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.topNode, 'div.dropzones div.dropzone');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dropZoneGroup(groupNo: number): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.topNode, `div.dropzones div.group${groupNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemsClonedFrom(dragItemNo: number): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, `.dragitems${dragItemNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItem(dragInstanceNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `.draginstance${dragInstanceNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemsInGroup(groupNo: number): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, `.drag.group${groupNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemHomes(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, '.draghome');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bgImg(): HTMLImageElement | null {
 | 
			
		||||
        return this.querySelector(this.topNode, '.dropbackground');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemHome(dragItemNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `.dragitemhomes${dragItemNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined {
 | 
			
		||||
        if (node.classList && node.classList.length) {
 | 
			
		||||
            const patt1 = new RegExp(`^${prefix}([0-9])+$`);
 | 
			
		||||
            const patt2 = new RegExp('([0-9])+$');
 | 
			
		||||
 | 
			
		||||
            for (let index = 0; index < node.classList.length; index++) {
 | 
			
		||||
                if (patt1.test(node.classList[index])) {
 | 
			
		||||
                    const match = patt2.exec(node.classList[index]);
 | 
			
		||||
 | 
			
		||||
                    return Number(match![0]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.logger.warn(`Prefix "${prefix}" not found in class names.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    cloneNewDragItem(dragInstanceNo: number, dragItemNo: number): HTMLElement | null {
 | 
			
		||||
        const dragHome = this.dragItemHome(dragItemNo);
 | 
			
		||||
        if (dragHome === null) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const dragHomeImg = dragHome.querySelector('img');
 | 
			
		||||
        let divDrag: HTMLElement | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
        // Images need to be inside a div element to admit padding with width and height.
 | 
			
		||||
        if (dragHomeImg) {
 | 
			
		||||
            // Clone the image.
 | 
			
		||||
            const drag = <HTMLElement> dragHomeImg.cloneNode(true);
 | 
			
		||||
 | 
			
		||||
            // Create a div and put the image in it.
 | 
			
		||||
            divDrag = document.createElement('div');
 | 
			
		||||
            divDrag.appendChild(drag);
 | 
			
		||||
            divDrag.className = dragHome.className;
 | 
			
		||||
            drag.className = '';
 | 
			
		||||
        } else {
 | 
			
		||||
            // The drag item doesn't have an image, just clone it.
 | 
			
		||||
            divDrag = <HTMLElement> dragHome.cloneNode(true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set the right classes and styles.
 | 
			
		||||
        divDrag.classList.remove(`dragitemhomes${dragItemNo}`);
 | 
			
		||||
        divDrag.classList.remove('draghome');
 | 
			
		||||
        divDrag.classList.add(`dragitems${dragItemNo}`);
 | 
			
		||||
        divDrag.classList.add(`draginstance${dragInstanceNo}`);
 | 
			
		||||
        divDrag.classList.add('drag');
 | 
			
		||||
 | 
			
		||||
        divDrag.style.visibility = 'inherit';
 | 
			
		||||
        divDrag.style.position = 'absolute';
 | 
			
		||||
        divDrag.setAttribute('draginstanceno', String(dragInstanceNo));
 | 
			
		||||
        divDrag.setAttribute('dragitemno', String(dragItemNo));
 | 
			
		||||
        divDrag.setAttribute('tabindex', '0');
 | 
			
		||||
 | 
			
		||||
        // Insert the new drag after the dragHome.
 | 
			
		||||
        dragHome.parentElement?.insertBefore(divDrag, dragHome.nextSibling);
 | 
			
		||||
 | 
			
		||||
        return divDrag;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddimageortext-container">
 | 
			
		||||
    <!-- Content is outside the core-loading to let the script calculate drag items position -->
 | 
			
		||||
    <core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
 | 
			
		||||
 | 
			
		||||
    <ion-item class="ion-text-wrap" [hidden]="!ddQuestion.loaded">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
 | 
			
		||||
                <ion-item>
 | 
			
		||||
                    <ion-icon name="fas-info-circle" slot="start"></ion-icon>
 | 
			
		||||
                    <ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-card>
 | 
			
		||||
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
 | 
			
		||||
                (afterRender)="textRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
 | 
			
		||||
            <core-format-text *ngIf="ddQuestion.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId"
 | 
			
		||||
                [text]="ddQuestion.ddArea" [filter]="false" (afterRender)="ddAreaRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										114
									
								
								src/addons/qtype/ddimageortext/component/ddimageortext.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/addons/qtype/ddimageortext/component/ddimageortext.scss
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										145
									
								
								src/addons/qtype/ddimageortext/component/ddimageortext.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								src/addons/qtype/ddimageortext/component/ddimageortext.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,145 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, OnDestroy, ElementRef } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a drag-and-drop onto image question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qtype-ddimageortext',
 | 
			
		||||
    templateUrl: 'addon-qtype-ddimageortext.html',
 | 
			
		||||
    styleUrls: ['ddimageortext.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    ddQuestion?: AddonModQuizDdImageOrTextQuestionData;
 | 
			
		||||
 | 
			
		||||
    protected questionInstance?: AddonQtypeDdImageOrTextQuestion;
 | 
			
		||||
    protected drops?: unknown[]; // The drop zones received in the init object of the question.
 | 
			
		||||
    protected destroyed = false;
 | 
			
		||||
    protected textIsRendered = false;
 | 
			
		||||
    protected ddAreaisRendered = false;
 | 
			
		||||
 | 
			
		||||
    constructor(elementRef: ElementRef) {
 | 
			
		||||
        super('AddonQtypeDdImageOrTextComponent', elementRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        if (!this.question) {
 | 
			
		||||
            this.logger.warn('Aborting because of no question received.');
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion = this.question;
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html);
 | 
			
		||||
 | 
			
		||||
        // Get D&D area and question text.
 | 
			
		||||
        const ddArea = element.querySelector('.ddarea');
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
 | 
			
		||||
        if (!ddArea || typeof this.ddQuestion.text == 'undefined') {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set the D&D area HTML.
 | 
			
		||||
        this.ddQuestion.ddArea = ddArea.outerHTML;
 | 
			
		||||
        this.ddQuestion.readOnly = false;
 | 
			
		||||
 | 
			
		||||
        if (this.ddQuestion.initObjects) {
 | 
			
		||||
            // Moodle version <= 3.5.
 | 
			
		||||
            if (typeof this.ddQuestion.initObjects.drops != 'undefined') {
 | 
			
		||||
                this.drops = <unknown[]> this.ddQuestion.initObjects.drops;
 | 
			
		||||
            }
 | 
			
		||||
            if (typeof this.ddQuestion.initObjects.readonly != 'undefined') {
 | 
			
		||||
                this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly;
 | 
			
		||||
            }
 | 
			
		||||
        } else if (this.ddQuestion.amdArgs) {
 | 
			
		||||
            // Moodle version >= 3.6.
 | 
			
		||||
            if (typeof this.ddQuestion.amdArgs[1] != 'undefined') {
 | 
			
		||||
                this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[1];
 | 
			
		||||
            }
 | 
			
		||||
            if (typeof this.ddQuestion.amdArgs[2] != 'undefined') {
 | 
			
		||||
                this.drops = <unknown[]> this.ddQuestion.amdArgs[2];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.loaded = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question ddArea has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    ddAreaRendered(): void {
 | 
			
		||||
        this.ddAreaisRendered = true;
 | 
			
		||||
        if (this.textIsRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question text has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    textRendered(): void {
 | 
			
		||||
        this.textIsRendered = true;
 | 
			
		||||
        if (this.ddAreaisRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    protected questionRendered(): void {
 | 
			
		||||
        if (!this.destroyed && this.ddQuestion) {
 | 
			
		||||
            // Create the instance.
 | 
			
		||||
            this.questionInstance = new AddonQtypeDdImageOrTextQuestion(
 | 
			
		||||
                this.hostElement,
 | 
			
		||||
                this.ddQuestion,
 | 
			
		||||
                !!this.ddQuestion.readOnly,
 | 
			
		||||
                this.drops,
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.destroyed = true;
 | 
			
		||||
        this.questionInstance?.destroy();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for DD Image or Text question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizDdImageOrTextQuestionData = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    loaded?: boolean;
 | 
			
		||||
    readOnly?: boolean;
 | 
			
		||||
    ddArea?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/ddimageortext/ddimageortext.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/ddimageortext/ddimageortext.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeDdImageOrTextComponent } from './component/ddimageortext';
 | 
			
		||||
import { AddonQtypeDdImageOrTextHandler } from './services/handlers/ddimageortext';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonQtypeDdImageOrTextComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdImageOrTextHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonQtypeDdImageOrTextComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDdImageOrTextModule {}
 | 
			
		||||
@ -0,0 +1,136 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeDdImageOrTextComponent } from '../../component/ddimageortext';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support drag-and-drop onto image question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeDdImageOrTextHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeDdImageOrText';
 | 
			
		||||
    type = 'qtype_ddimageortext';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeDdImageOrTextComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // An answer is complete if all drop zones have an answer.
 | 
			
		||||
        // We should always receive all the drop zones with their value ('' if not answered).
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (!value || value === '0') {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (value && value !== '0') {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeDdImageOrTextHandler extends makeSingleton(AddonQtypeDdImageOrTextHandlerService) {}
 | 
			
		||||
							
								
								
									
										972
									
								
								src/addons/qtype/ddmarker/classes/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										972
									
								
								src/addons/qtype/ddmarker/classes/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,972 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { AddonQtypeDdMarkerQuestionData } from '../component/ddmarker';
 | 
			
		||||
import { AddonQtypeDdMarkerGraphicsApi } from './graphics_api';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Point type.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonQtypeDdMarkerQuestionPoint = {
 | 
			
		||||
    x: number; // X axis coordinates.
 | 
			
		||||
    y: number; // Y axis coordinates.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class to make a question of ddmarker type work.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdMarkerQuestion {
 | 
			
		||||
 | 
			
		||||
    protected readonly COLOURS = ['#FFFFFF', '#B0C4DE', '#DCDCDC', '#D8BFD8', '#87CEFA', '#DAA520', '#FFD700', '#F0E68C'];
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
    protected afterImageLoadDone = false;
 | 
			
		||||
    protected drops;
 | 
			
		||||
    protected topNode;
 | 
			
		||||
    protected nextColourIndex = 0;
 | 
			
		||||
    protected proportion = 1;
 | 
			
		||||
    protected selected?: HTMLElement; // Selected element (being "dragged").
 | 
			
		||||
    protected graphics: AddonQtypeDdMarkerGraphicsApi;
 | 
			
		||||
    protected resizeFunction?: () => void;
 | 
			
		||||
 | 
			
		||||
    doc!: AddonQtypeDdMarkerQuestionDocStructure;
 | 
			
		||||
    shapes: SVGElement[] = [];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create the instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param container The container HTMLElement of the question.
 | 
			
		||||
     * @param question The question instance.
 | 
			
		||||
     * @param readOnly Whether it's read only.
 | 
			
		||||
     * @param dropZones The drop zones received in the init object of the question.
 | 
			
		||||
     * @param imgSrc Background image source (3.6+ sites).
 | 
			
		||||
     */
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected container: HTMLElement,
 | 
			
		||||
        protected question: AddonQtypeDdMarkerQuestionData,
 | 
			
		||||
        protected readOnly: boolean,
 | 
			
		||||
        protected dropZones: any[], // eslint-disable-line @typescript-eslint/no-explicit-any
 | 
			
		||||
        protected imgSrc?: string,
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestion');
 | 
			
		||||
 | 
			
		||||
        this.graphics = new AddonQtypeDdMarkerGraphicsApi(this);
 | 
			
		||||
 | 
			
		||||
        this.initializer();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Calculate image proportion to make easy conversions.
 | 
			
		||||
     */
 | 
			
		||||
    calculateImgProportion(): void {
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Render the position related to the current image dimensions.
 | 
			
		||||
        this.proportion = 1;
 | 
			
		||||
        if (bgImg.width != bgImg.naturalWidth) {
 | 
			
		||||
            this.proportion = bgImg.width / bgImg.naturalWidth;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new draggable element cloning a certain element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dragHome The element to clone.
 | 
			
		||||
     * @param itemNo The number of the new item.
 | 
			
		||||
     * @return The new element.
 | 
			
		||||
     */
 | 
			
		||||
    cloneNewDragItem(dragHome: HTMLElement, itemNo: number): HTMLElement {
 | 
			
		||||
        // Clone the element and add the right classes.
 | 
			
		||||
        const drag = <HTMLElement> dragHome.cloneNode(true);
 | 
			
		||||
        drag.classList.remove('draghome');
 | 
			
		||||
        drag.classList.add('dragitem');
 | 
			
		||||
        drag.classList.add('item' + itemNo);
 | 
			
		||||
        drag.classList.remove('dragplaceholder'); // In case it has it.
 | 
			
		||||
        dragHome.classList.add('dragplaceholder');
 | 
			
		||||
 | 
			
		||||
        // Insert the new drag after the dragHome.
 | 
			
		||||
        dragHome.parentElement?.insertBefore(drag, dragHome.nextSibling);
 | 
			
		||||
        if (!this.readOnly) {
 | 
			
		||||
            this.draggable(drag);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return drag;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert the X and Y position of the BG IMG to a position relative to the window.
 | 
			
		||||
     *
 | 
			
		||||
     * @param bgImgXY X and Y of the BG IMG relative position.
 | 
			
		||||
     * @return Position relative to the window.
 | 
			
		||||
     */
 | 
			
		||||
    convertToWindowXY(bgImgXY: string): number[] {
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
 | 
			
		||||
        let coordsNumbers = this.parsePoint(bgImgXY);
 | 
			
		||||
 | 
			
		||||
        coordsNumbers = this.makePointProportional(coordsNumbers);
 | 
			
		||||
 | 
			
		||||
        return [coordsNumbers.x + position[0], coordsNumbers.y + position[1]];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if some coordinates (X, Y) are inside the background image.
 | 
			
		||||
     *
 | 
			
		||||
     * @param coords Coordinates to check.
 | 
			
		||||
     * @return Whether they're inside the background image.
 | 
			
		||||
     */
 | 
			
		||||
    coordsInImg(coords: AddonQtypeDdMarkerQuestionPoint): boolean {
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (coords.x * this.proportion <= bgImg.width + 1) && (coords.y * this.proportion <= bgImg.height + 1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Deselect all draggable items.
 | 
			
		||||
     */
 | 
			
		||||
    deselectDrags(): void {
 | 
			
		||||
        const drags = this.doc.dragItems();
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            drag.classList.remove('beingdragged');
 | 
			
		||||
        });
 | 
			
		||||
        this.selected = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function to call when the instance is no longer needed.
 | 
			
		||||
     */
 | 
			
		||||
    destroy(): void {
 | 
			
		||||
        if (this.resizeFunction) {
 | 
			
		||||
            window.removeEventListener('resize', this.resizeFunction);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element.
 | 
			
		||||
     */
 | 
			
		||||
    draggable(drag: HTMLElement): void {
 | 
			
		||||
        drag.addEventListener('click', (e) => {
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
            const dragging = this.selected;
 | 
			
		||||
            if (dragging && !drag.classList.contains('unplaced')) {
 | 
			
		||||
 | 
			
		||||
                const position = CoreDomUtils.instance.getElementXY(drag, undefined, 'ddarea');
 | 
			
		||||
                const bgImg = this.doc.bgImg();
 | 
			
		||||
                if (!bgImg) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const bgImgPos = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
 | 
			
		||||
 | 
			
		||||
                position[0] = position[0] - bgImgPos[0] + e.offsetX;
 | 
			
		||||
                position[1] = position[1] - bgImgPos[1] + e.offsetY;
 | 
			
		||||
 | 
			
		||||
                // Ensure the we click on a placed dragitem.
 | 
			
		||||
                if (position[0] <= bgImg.width && position[1] <= bgImg.height) {
 | 
			
		||||
                    this.deselectDrags();
 | 
			
		||||
                    this.dropDrag(dragging, position);
 | 
			
		||||
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (drag.classList.contains('beingdragged')) {
 | 
			
		||||
                this.deselectDrags();
 | 
			
		||||
            } else {
 | 
			
		||||
                this.selectDrag(drag);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the coordinates of the drag home of a certain choice.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choiceNo Choice number.
 | 
			
		||||
     * @return Coordinates.
 | 
			
		||||
     */
 | 
			
		||||
    dragHomeXY(choiceNo: number): number[] {
 | 
			
		||||
        const dragItemHome = this.doc.dragItemHome(choiceNo);
 | 
			
		||||
        if (!dragItemHome) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, 'ddarea');
 | 
			
		||||
 | 
			
		||||
        return [position[0], position[1]];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Draw a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dropZoneNo Number of the drop zone.
 | 
			
		||||
     * @param markerText The marker text to set.
 | 
			
		||||
     * @param shape Name of the shape of the drop zone (circle, rectangle, polygon).
 | 
			
		||||
     * @param coords Coordinates of the shape.
 | 
			
		||||
     * @param colour Colour of the shape.
 | 
			
		||||
     */
 | 
			
		||||
    drawDropZone(dropZoneNo: number, markerText: string, shape: string, coords: string, colour: string): void {
 | 
			
		||||
        const markerTexts = this.doc.markerTexts();
 | 
			
		||||
        // Check if there is already a marker text for this drop zone.
 | 
			
		||||
        const existingMarkerText = markerTexts?.querySelector<HTMLElement>('span.markertext' + dropZoneNo);
 | 
			
		||||
 | 
			
		||||
        if (existingMarkerText) {
 | 
			
		||||
            // Marker text already exists. Update it or remove it if empty.
 | 
			
		||||
            if (markerText !== '') {
 | 
			
		||||
                existingMarkerText.innerHTML = markerText;
 | 
			
		||||
            } else {
 | 
			
		||||
                existingMarkerText.remove();
 | 
			
		||||
            }
 | 
			
		||||
        } else if (markerText !== '' && markerTexts) {
 | 
			
		||||
            // Create and add the marker text.
 | 
			
		||||
            const classNames = 'markertext markertext' + dropZoneNo;
 | 
			
		||||
            const span = document.createElement('span');
 | 
			
		||||
 | 
			
		||||
            span.className = classNames;
 | 
			
		||||
            span.innerHTML = markerText;
 | 
			
		||||
 | 
			
		||||
            markerTexts.appendChild(span);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check that a function to draw this shape exists.
 | 
			
		||||
        const drawFunc = 'drawShape' + CoreTextUtils.instance.ucFirst(shape);
 | 
			
		||||
        if (!(this[drawFunc] instanceof Function)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Call the function.
 | 
			
		||||
        const xyForText = this[drawFunc](dropZoneNo, coords, colour);
 | 
			
		||||
        if (xyForText === null || xyForText === undefined) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Search the marker for the drop zone.
 | 
			
		||||
        const markerSpan = this.doc.topNode?.querySelector<HTMLElement>(`div.ddarea div.markertexts span.markertext${dropZoneNo}`);
 | 
			
		||||
        if (!markerSpan) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const width = CoreDomUtils.instance.getElementMeasure(markerSpan, true, true, false, true);
 | 
			
		||||
        const height = CoreDomUtils.instance.getElementMeasure(markerSpan, false, true, false, true);
 | 
			
		||||
        markerSpan.style.opacity = '0.6';
 | 
			
		||||
        markerSpan.style.left = (xyForText.x - (width / 2)) + 'px';
 | 
			
		||||
        markerSpan.style.top = (xyForText.y - (height / 2)) + 'px';
 | 
			
		||||
 | 
			
		||||
        const markerSpanAnchor = markerSpan.querySelector('a');
 | 
			
		||||
        if (markerSpanAnchor !== null) {
 | 
			
		||||
 | 
			
		||||
            markerSpanAnchor.addEventListener('click', (e) => {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
                this.shapes.forEach((elem) => {
 | 
			
		||||
                    elem.style.fillOpacity = '0.5';
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                this.shapes[dropZoneNo].style.fillOpacity = '1';
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.shapes[dropZoneNo].style.fillOpacity = '0.5';
 | 
			
		||||
                }, 2000);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            markerSpanAnchor.setAttribute('tabIndex', '0');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Draw a circle in a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dropZoneNo Number of the drop zone.
 | 
			
		||||
     * @param coordinates Coordinates of the circle.
 | 
			
		||||
     * @param colour Colour of the circle.
 | 
			
		||||
     * @return X and Y position of the center of the circle.
 | 
			
		||||
     */
 | 
			
		||||
    drawShapeCircle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
 | 
			
		||||
        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/)) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const bits = coordinates.split(';');
 | 
			
		||||
        let centre = this.parsePoint(bits[0]);
 | 
			
		||||
        const radius = Number(bits[1]);
 | 
			
		||||
 | 
			
		||||
        // Calculate circle limits and check it's inside the background image.
 | 
			
		||||
        const circleLimit = { x: centre.x - radius, y: centre.y - radius };
 | 
			
		||||
        if (this.coordsInImg(circleLimit)) {
 | 
			
		||||
            centre = this.makePointProportional(centre);
 | 
			
		||||
 | 
			
		||||
            // All good, create the shape.
 | 
			
		||||
            this.shapes[dropZoneNo] = this.graphics.addShape({
 | 
			
		||||
                type: 'circle',
 | 
			
		||||
                color: colour,
 | 
			
		||||
            }, {
 | 
			
		||||
                cx: centre.x,
 | 
			
		||||
                cy: centre.y,
 | 
			
		||||
                r: Math.round(radius * this.proportion),
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Return the centre.
 | 
			
		||||
            return centre;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Draw a rectangle in a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dropZoneNo Number of the drop zone.
 | 
			
		||||
     * @param coordinates Coordinates of the rectangle.
 | 
			
		||||
     * @param colour Colour of the rectangle.
 | 
			
		||||
     * @return X and Y position of the center of the rectangle.
 | 
			
		||||
     */
 | 
			
		||||
    drawShapeRectangle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
 | 
			
		||||
        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/)) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const bits = coordinates.split(';');
 | 
			
		||||
        const startPoint = this.parsePoint(bits[0]);
 | 
			
		||||
        const size = this.parsePoint(bits[1]);
 | 
			
		||||
 | 
			
		||||
        // Calculate rectangle limits and check it's inside the background image.
 | 
			
		||||
        const rectLimits = { x: startPoint.x + size.x, y: startPoint.y + size.y };
 | 
			
		||||
        if (this.coordsInImg(rectLimits)) {
 | 
			
		||||
            const startPointProp = this.makePointProportional(startPoint);
 | 
			
		||||
            const sizeProp = this.makePointProportional(size);
 | 
			
		||||
 | 
			
		||||
            // All good, create the shape.
 | 
			
		||||
            this.shapes[dropZoneNo] = this.graphics.addShape({
 | 
			
		||||
                type: 'rect',
 | 
			
		||||
                color: colour,
 | 
			
		||||
            }, {
 | 
			
		||||
                x: startPointProp.x,
 | 
			
		||||
                y: startPointProp.y,
 | 
			
		||||
                width: sizeProp.x,
 | 
			
		||||
                height: sizeProp.y,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const centre = { x: startPoint.x + (size.x / 2) , y: startPoint.y + (size.y / 2) };
 | 
			
		||||
 | 
			
		||||
            // Return the centre.
 | 
			
		||||
            return this.makePointProportional(centre);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Draw a polygon in a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dropZoneNo Number of the drop zone.
 | 
			
		||||
     * @param coordinates Coordinates of the polygon.
 | 
			
		||||
     * @param colour Colour of the polygon.
 | 
			
		||||
     * @return X and Y position of the center of the polygon.
 | 
			
		||||
     */
 | 
			
		||||
    drawShapePolygon(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
 | 
			
		||||
        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?(?:;\d+(\.\d+)?,\d+(\.\d+)?)*$/)) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const bits = coordinates.split(';');
 | 
			
		||||
        const centre = { x: 0, y: 0 };
 | 
			
		||||
        const points = bits.map((bit) => {
 | 
			
		||||
            const point = this.parsePoint(bit);
 | 
			
		||||
            centre.x += point.x;
 | 
			
		||||
            centre.y += point.y;
 | 
			
		||||
 | 
			
		||||
            return point;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (points.length > 0) {
 | 
			
		||||
            centre.x = Math.round(centre.x / points.length);
 | 
			
		||||
            centre.y = Math.round(centre.y / points.length);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const pointsOnImg: string[] = [];
 | 
			
		||||
        points.forEach((point) => {
 | 
			
		||||
            if (this.coordsInImg(point)) {
 | 
			
		||||
                point = this.makePointProportional(point);
 | 
			
		||||
 | 
			
		||||
                pointsOnImg.push(point.x + ',' + point.y);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (pointsOnImg.length > 2) {
 | 
			
		||||
            this.shapes[dropZoneNo] = this.graphics.addShape({
 | 
			
		||||
                type: 'polygon',
 | 
			
		||||
                color: colour,
 | 
			
		||||
            }, {
 | 
			
		||||
                points: pointsOnImg.join(' '),
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Return the centre.
 | 
			
		||||
            return this.makePointProportional(centre);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make a point from the string representation.
 | 
			
		||||
     *
 | 
			
		||||
     * @param coordinates "x,y".
 | 
			
		||||
     * @return Coordinates to the point.
 | 
			
		||||
     */
 | 
			
		||||
    parsePoint(coordinates: string): AddonQtypeDdMarkerQuestionPoint {
 | 
			
		||||
        const bits = coordinates.split(',');
 | 
			
		||||
        if (bits.length !== 2) {
 | 
			
		||||
            throw coordinates + ' is not a valid point';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return { x: Number(bits[0]), y: Number(bits[1]) };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make proportional position of the point.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  point Point coordinates.
 | 
			
		||||
     * @return Converted point.
 | 
			
		||||
     */
 | 
			
		||||
    makePointProportional(point: AddonQtypeDdMarkerQuestionPoint): AddonQtypeDdMarkerQuestionPoint {
 | 
			
		||||
        return {
 | 
			
		||||
            x: Math.round(point.x * this.proportion),
 | 
			
		||||
            y: Math.round(point.y * this.proportion),
 | 
			
		||||
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Drop a drag element into a certain position.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag The element to drop.
 | 
			
		||||
     * @param position Position to drop to (X, Y).
 | 
			
		||||
     */
 | 
			
		||||
    dropDrag(drag: HTMLElement, position: number[] | null): void {
 | 
			
		||||
        const choiceNo = this.getChoiceNoForNode(drag);
 | 
			
		||||
 | 
			
		||||
        if (position) {
 | 
			
		||||
            // Set the position related to the natural image dimensions.
 | 
			
		||||
            if (this.proportion < 1) {
 | 
			
		||||
                position[0] = Math.round(position[0] / this.proportion);
 | 
			
		||||
                position[1] = Math.round(position[1] / this.proportion);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.saveAllXYForChoice(choiceNo, drag, position);
 | 
			
		||||
        this.redrawDragsAndDrops();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine which drag items need to be shown and return coords of all drag items except any that are currently being
 | 
			
		||||
     * dragged based on contents of hidden inputs and whether drags are 'infinite' or how many drags should be shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param input The input element.
 | 
			
		||||
     * @return List of coordinates.
 | 
			
		||||
     */
 | 
			
		||||
    getCoords(input: HTMLElement): number[][] {
 | 
			
		||||
        const choiceNo = this.getChoiceNoForNode(input);
 | 
			
		||||
        const fv = input.getAttribute('value');
 | 
			
		||||
        const infinite = input.classList.contains('infinite');
 | 
			
		||||
        const noOfDrags = this.getNoOfDragsForNode(input);
 | 
			
		||||
        const dragging = !!this.doc.dragItemBeingDragged(choiceNo);
 | 
			
		||||
        const coords: number[][] = [];
 | 
			
		||||
 | 
			
		||||
        if (fv !== '' && typeof fv != 'undefined' && fv !== null) {
 | 
			
		||||
            // Get all the coordinates in the input and add them to the coords list.
 | 
			
		||||
            const coordsStrings = fv.split(';');
 | 
			
		||||
 | 
			
		||||
            for (let i = 0; i < coordsStrings.length; i++) {
 | 
			
		||||
                coords[coords.length] = this.convertToWindowXY(coordsStrings[i]);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const displayedDrags = coords.length + (dragging ? 1 : 0);
 | 
			
		||||
        if (infinite || (displayedDrags < noOfDrags)) {
 | 
			
		||||
            coords[coords.length] = this.dragHomeXY(choiceNo);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return coords;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the choice number from an HTML element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Choice number.
 | 
			
		||||
     */
 | 
			
		||||
    getChoiceNoForNode(node: HTMLElement): number {
 | 
			
		||||
        return Number(this.doc.getClassnameNumericSuffix(node, 'choice'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the coordinates (X, Y) of a draggable element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dragItem The draggable item.
 | 
			
		||||
     * @return Coordinates.
 | 
			
		||||
     */
 | 
			
		||||
    getDragXY(dragItem: HTMLElement): number[] {
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(dragItem, undefined, 'ddarea');
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (bgImg) {
 | 
			
		||||
            const bgImgXY = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
 | 
			
		||||
 | 
			
		||||
            position[0] -= bgImgXY[0];
 | 
			
		||||
            position[1] -= bgImgXY[1];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set the position related to the natural image dimensions.
 | 
			
		||||
        if (this.proportion < 1) {
 | 
			
		||||
            position[0] = Math.round(position[0] / this.proportion);
 | 
			
		||||
            position[1] = Math.round(position[1] / this.proportion);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return position;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the item number from an HTML element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Choice number.
 | 
			
		||||
     */
 | 
			
		||||
    getItemNoForNode(node: HTMLElement): number {
 | 
			
		||||
        return Number(this.doc.getClassnameNumericSuffix(node, 'item'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the next colour.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Colour.
 | 
			
		||||
     */
 | 
			
		||||
    getNextColour(): string {
 | 
			
		||||
        const colour = this.COLOURS[this.nextColourIndex];
 | 
			
		||||
        this.nextColourIndex++;
 | 
			
		||||
 | 
			
		||||
        // If we reached the end of the list, start again.
 | 
			
		||||
        if (this.nextColourIndex === this.COLOURS.length) {
 | 
			
		||||
            this.nextColourIndex = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return colour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the number of drags from an HTML element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Choice number.
 | 
			
		||||
     */
 | 
			
		||||
    getNoOfDragsForNode(node: HTMLElement): number {
 | 
			
		||||
        return Number(this.doc.getClassnameNumericSuffix(node, 'noofdrags'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     */
 | 
			
		||||
    initializer(): void {
 | 
			
		||||
        this.doc = new AddonQtypeDdMarkerQuestionDocStructure(this.container);
 | 
			
		||||
 | 
			
		||||
        // Wait the DOM to be rendered.
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            this.pollForImageLoad();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.resizeFunction = this.redrawDragsAndDrops.bind(this);
 | 
			
		||||
        window.addEventListener('resize', this.resizeFunction!);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make background image and home zone dropable.
 | 
			
		||||
     */
 | 
			
		||||
    makeImageDropable(): void {
 | 
			
		||||
        if (this.readOnly) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Listen for click events in the background image to make it dropable.
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        bgImg?.addEventListener('click', (e) => {
 | 
			
		||||
 | 
			
		||||
            const drag = this.selected;
 | 
			
		||||
            if (!drag) {
 | 
			
		||||
                // No draggable element selected, nothing to do.
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // There's an element being dragged. Deselect it and drop it in the position.
 | 
			
		||||
            const position = [e.offsetX, e.offsetY];
 | 
			
		||||
            this.deselectDrags();
 | 
			
		||||
            this.dropDrag(drag, position);
 | 
			
		||||
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const home = this.doc.dragItemsArea;
 | 
			
		||||
        home?.addEventListener('click', (e) => {
 | 
			
		||||
 | 
			
		||||
            const drag = this.selected;
 | 
			
		||||
            if (!drag) {
 | 
			
		||||
                // No draggable element selected, nothing to do.
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // There's an element being dragged but it's not placed yet, deselect.
 | 
			
		||||
            if (drag.classList.contains('unplaced')) {
 | 
			
		||||
                this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // There's an element being dragged and it's placed somewhere. Move it back to the home area.
 | 
			
		||||
            this.deselectDrags();
 | 
			
		||||
            this.dropDrag(drag, null);
 | 
			
		||||
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Wait for the background image to be loaded.
 | 
			
		||||
     */
 | 
			
		||||
    pollForImageLoad(): void {
 | 
			
		||||
        if (this.afterImageLoadDone) {
 | 
			
		||||
            // Already treated.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const bgImg = this.doc.bgImg();
 | 
			
		||||
        if (!bgImg) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!bgImg.src && this.imgSrc) {
 | 
			
		||||
            bgImg.src = this.imgSrc;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const imgLoaded = (): void => {
 | 
			
		||||
            bgImg.removeEventListener('load', imgLoaded);
 | 
			
		||||
 | 
			
		||||
            this.makeImageDropable();
 | 
			
		||||
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                this.redrawDragsAndDrops();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.afterImageLoadDone = true;
 | 
			
		||||
            this.question.loaded = true;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (!bgImg.src || (bgImg.complete && bgImg.naturalWidth)) {
 | 
			
		||||
            imgLoaded();
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        bgImg.addEventListener('load', imgLoaded);
 | 
			
		||||
 | 
			
		||||
        // Try again after a while.
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            this.pollForImageLoad();
 | 
			
		||||
        }, 500);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Redraw all draggables and drop zones.
 | 
			
		||||
     */
 | 
			
		||||
    redrawDragsAndDrops(): void {
 | 
			
		||||
        // Mark all the draggable items as not placed.
 | 
			
		||||
        const drags = this.doc.dragItems();
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            drag.classList.add('unneeded', 'unplaced');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Re-calculate the image proportion.
 | 
			
		||||
        this.calculateImgProportion();
 | 
			
		||||
 | 
			
		||||
        // Get all the inputs.
 | 
			
		||||
        const inputs = this.doc.inputsForChoices();
 | 
			
		||||
        for (let x = 0; x < inputs.length; x++) {
 | 
			
		||||
 | 
			
		||||
            // Get all the drag items for the choice.
 | 
			
		||||
            const input = inputs[x];
 | 
			
		||||
            const choiceNo = this.getChoiceNoForNode(input);
 | 
			
		||||
            const coords = this.getCoords(input);
 | 
			
		||||
            const dragItemHome = this.doc.dragItemHome(choiceNo);
 | 
			
		||||
            const homePosition = this.dragHomeXY(choiceNo);
 | 
			
		||||
            if (!dragItemHome) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let i = 0; i < coords.length; i++) {
 | 
			
		||||
                let dragItem = this.doc.dragItemForChoice(choiceNo, i);
 | 
			
		||||
 | 
			
		||||
                if (!dragItem || dragItem.classList.contains('beingdragged')) {
 | 
			
		||||
                    dragItem = this.cloneNewDragItem(dragItemHome, i);
 | 
			
		||||
                } else {
 | 
			
		||||
                    dragItem.classList.remove('unneeded');
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const placeholder = this.doc.dragItemPlaceholder(choiceNo);
 | 
			
		||||
 | 
			
		||||
                // Remove the class only if is placed on the image.
 | 
			
		||||
                if (homePosition[0] != coords[i][0] || homePosition[1] != coords[i][1]) {
 | 
			
		||||
                    dragItem.classList.remove('unplaced');
 | 
			
		||||
                    dragItem.classList.add('placed');
 | 
			
		||||
 | 
			
		||||
                    const computedStyle = getComputedStyle(dragItem);
 | 
			
		||||
                    const left = coords[i][0] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginLeft');
 | 
			
		||||
                    const top = coords[i][1] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginTop');
 | 
			
		||||
 | 
			
		||||
                    dragItem.style.left = left + 'px';
 | 
			
		||||
                    dragItem.style.top = top + 'px';
 | 
			
		||||
                    placeholder?.classList.add('active');
 | 
			
		||||
                } else {
 | 
			
		||||
                    dragItem.classList.remove('placed');
 | 
			
		||||
                    dragItem.classList.add('unplaced');
 | 
			
		||||
                    placeholder?.classList.remove('active');
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove unneeded draggable items.
 | 
			
		||||
        for (let y = 0; y < drags.length; y++) {
 | 
			
		||||
            const item = drags[y];
 | 
			
		||||
            if (item.classList.contains('unneeded') && !item.classList.contains('beingdragged')) {
 | 
			
		||||
                item.remove();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Re-draw drop zones.
 | 
			
		||||
        if (this.dropZones && this.dropZones.length !== 0) {
 | 
			
		||||
            this.graphics.clear();
 | 
			
		||||
            this.restartColours();
 | 
			
		||||
 | 
			
		||||
            for (const dropZoneNo in this.dropZones) {
 | 
			
		||||
                const colourForDropZone = this.getNextColour();
 | 
			
		||||
                const dropZone = this.dropZones[dropZoneNo];
 | 
			
		||||
                const dzNo = Number(dropZoneNo);
 | 
			
		||||
 | 
			
		||||
                this.drawDropZone(dzNo, dropZone.markertext, dropZone.shape, dropZone.coords, colourForDropZone);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reset the coordinates stored for a choice.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choiceNo Choice number.
 | 
			
		||||
     */
 | 
			
		||||
    resetDragXY(choiceNo: number): void {
 | 
			
		||||
        this.setFormValue(choiceNo, '');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Restart the colour index.
 | 
			
		||||
     */
 | 
			
		||||
    restartColours(): void {
 | 
			
		||||
        this.nextColourIndex = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save all the coordinates of a choice into the right input.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choiceNo Number of the choice.
 | 
			
		||||
     * @param dropped Element being dropped.
 | 
			
		||||
     * @param position Position where the element is dropped.
 | 
			
		||||
     */
 | 
			
		||||
    saveAllXYForChoice(choiceNo: number, dropped: HTMLElement, position: number[] | null): void {
 | 
			
		||||
        const coords: number[][] = [];
 | 
			
		||||
 | 
			
		||||
        // Calculate the coords for the choice.
 | 
			
		||||
        const dragItemsChoice = this.doc.dragItemsForChoice(choiceNo);
 | 
			
		||||
        for (let i = 0; i < dragItemsChoice.length; i++) {
 | 
			
		||||
 | 
			
		||||
            const dragItem = this.doc.dragItemForChoice(choiceNo, i);
 | 
			
		||||
            if (dragItem) {
 | 
			
		||||
                const bgImgXY = this.getDragXY(dragItem);
 | 
			
		||||
                dragItem.classList.remove('item' + i);
 | 
			
		||||
                dragItem.classList.add('item' + coords.length);
 | 
			
		||||
                coords.push(bgImgXY);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (position !== null) {
 | 
			
		||||
            // Element dropped into a certain position. Mark it as placed and save the position.
 | 
			
		||||
            dropped.classList.remove('unplaced');
 | 
			
		||||
            dropped.classList.add('item' + coords.length);
 | 
			
		||||
            coords.push(position);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Element back at home, mark it as unplaced.
 | 
			
		||||
            dropped.classList.add('unplaced');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (coords.length > 0) {
 | 
			
		||||
            // Save the coordinates in the input.
 | 
			
		||||
            this.setFormValue(choiceNo, coords.join(';'));
 | 
			
		||||
        } else {
 | 
			
		||||
            // Empty the input.
 | 
			
		||||
            this.resetDragXY(choiceNo);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save a certain value in the input of a choice.
 | 
			
		||||
     *
 | 
			
		||||
     * @param choiceNo Choice number.
 | 
			
		||||
     * @param value The value to set.
 | 
			
		||||
     */
 | 
			
		||||
    setFormValue(choiceNo: number, value: string): void {
 | 
			
		||||
        this.doc.inputForChoice(choiceNo)?.setAttribute('value', value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Select a draggable element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element.
 | 
			
		||||
     */
 | 
			
		||||
    selectDrag(drag: HTMLElement): void {
 | 
			
		||||
        // Deselect previous drags.
 | 
			
		||||
        this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
        this.selected = drag;
 | 
			
		||||
        drag.classList.add('beingdragged');
 | 
			
		||||
 | 
			
		||||
        const itemNo = this.getItemNoForNode(drag);
 | 
			
		||||
        if (itemNo !== null) {
 | 
			
		||||
            drag.classList.remove('item' + itemNo);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encapsulates operations on dd area.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdMarkerQuestionDocStructure {
 | 
			
		||||
 | 
			
		||||
    topNode: HTMLElement | null;
 | 
			
		||||
    dragItemsArea: HTMLElement | null;
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected container: HTMLElement,
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestionDocStructure');
 | 
			
		||||
 | 
			
		||||
        this.topNode = this.container.querySelector<HTMLElement>('.addon-qtype-ddmarker-container');
 | 
			
		||||
        this.dragItemsArea = this.topNode?.querySelector<HTMLElement>('div.dragitems, div.draghomes') || null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    querySelector<T = HTMLElement>(element: HTMLElement | null, selector: string): T | null {
 | 
			
		||||
        if (!element) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return <T | null> element.querySelector(selector);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] {
 | 
			
		||||
        if (!element) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Array.from(element.querySelectorAll(selector));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bgImg(): HTMLImageElement | null {
 | 
			
		||||
        return this.querySelector(this.topNode, '.dropbackground');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItems(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, '.dragitem');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemsForChoice(choiceNo: number): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, `span.dragitem.choice${choiceNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemForChoice(choiceNo: number, itemNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `span.dragitem.choice${choiceNo}.item${itemNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemPlaceholder(choiceNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `span.dragplaceholder.choice${choiceNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemBeingDragged(choiceNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `span.dragitem.beingdragged.choice${choiceNo}`);
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemHome(choiceNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.dragItemsArea, `span.draghome.choice${choiceNo}, span.marker.choice${choiceNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragItemHomes(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.dragItemsArea, 'span.draghome, span.marker');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined {
 | 
			
		||||
        if (node.classList.length) {
 | 
			
		||||
            const patt1 = new RegExp('^' + prefix + '([0-9])+$');
 | 
			
		||||
            const patt2 = new RegExp('([0-9])+$');
 | 
			
		||||
 | 
			
		||||
            for (let index = 0; index < node.classList.length; index++) {
 | 
			
		||||
                if (patt1.test(node.classList[index])) {
 | 
			
		||||
                    const match = patt2.exec(node.classList[index]);
 | 
			
		||||
 | 
			
		||||
                    return Number(match?.[0]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.logger.warn('Prefix "' + prefix + '" not found in class names.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    inputsForChoices(): HTMLElement[] {
 | 
			
		||||
        return this.querySelectorAll(this.topNode, 'input.choices');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    inputForChoice(choiceNo: number): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.topNode, `input.choice${choiceNo}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    markerTexts(): HTMLElement | null {
 | 
			
		||||
        return this.querySelector(this.topNode, 'div.markertexts');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										96
									
								
								src/addons/qtype/ddmarker/classes/graphics_api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/addons/qtype/ddmarker/classes/graphics_api.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,96 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { AddonQtypeDdMarkerQuestion } from './ddmarker';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Graphics API for drag-and-drop markers question type.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdMarkerGraphicsApi {
 | 
			
		||||
 | 
			
		||||
    protected readonly NS = 'http://www.w3.org/2000/svg';
 | 
			
		||||
    protected dropZone?: SVGSVGElement;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create the instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param instance Question instance.
 | 
			
		||||
     * @param domUtils Dom Utils provider.
 | 
			
		||||
     */
 | 
			
		||||
    constructor(protected instance: AddonQtypeDdMarkerQuestion) { }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add a shape.
 | 
			
		||||
     *
 | 
			
		||||
     * @param shapeAttribs Attributes for the shape: type and color.
 | 
			
		||||
     * @param styles Object with the styles for the shape (name -> value).
 | 
			
		||||
     * @return The new shape.
 | 
			
		||||
     */
 | 
			
		||||
    addShape(shapeAttribs: {type: string; color: string}, styles: {[name: string]: number | string}): SVGElement {
 | 
			
		||||
        const shape = document.createElementNS(this.NS, shapeAttribs.type);
 | 
			
		||||
        shape.setAttribute('fill', shapeAttribs.color);
 | 
			
		||||
        shape.setAttribute('fill-opacity', '0.5');
 | 
			
		||||
        shape.setAttribute('stroke', 'black');
 | 
			
		||||
 | 
			
		||||
        for (const x in styles) {
 | 
			
		||||
            shape.setAttribute(x, String(styles[x]));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.dropZone?.appendChild(shape);
 | 
			
		||||
 | 
			
		||||
        return shape;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clear the shapes.
 | 
			
		||||
     */
 | 
			
		||||
    clear(): void {
 | 
			
		||||
        const bgImg = this.instance.doc?.bgImg();
 | 
			
		||||
        const dropZones = this.instance.doc?.topNode?.querySelector<HTMLElement>('div.ddarea div.dropzones');
 | 
			
		||||
        const markerTexts = this.instance.doc?.markerTexts();
 | 
			
		||||
 | 
			
		||||
        if (!bgImg || !dropZones || !markerTexts) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
 | 
			
		||||
 | 
			
		||||
        dropZones.style.left = position[0] + 'px';
 | 
			
		||||
        dropZones.style.top = position[1] + 'px';
 | 
			
		||||
        dropZones.style.width = bgImg.width + 'px';
 | 
			
		||||
        dropZones.style.height = bgImg.height + 'px';
 | 
			
		||||
 | 
			
		||||
        markerTexts.style.left = position[0] + 'px';
 | 
			
		||||
        markerTexts.style.top = position[1] + 'px';
 | 
			
		||||
        markerTexts.style.width = bgImg.width + 'px';
 | 
			
		||||
        markerTexts.style.height = bgImg.height + 'px';
 | 
			
		||||
 | 
			
		||||
        if (!this.dropZone) {
 | 
			
		||||
            this.dropZone = <SVGSVGElement> document.createElementNS(this.NS, 'svg');
 | 
			
		||||
            dropZones.appendChild(this.dropZone);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Remove all children.
 | 
			
		||||
            while (this.dropZone.firstChild) {
 | 
			
		||||
                this.dropZone.removeChild(this.dropZone.firstChild);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.dropZone.style.width = bgImg.width + 'px';
 | 
			
		||||
        this.dropZone.style.height = bgImg.height + 'px';
 | 
			
		||||
 | 
			
		||||
        this.instance.shapes = [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,22 @@
 | 
			
		||||
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddmarker-container">
 | 
			
		||||
    <!-- Content is outside the core-loading to let the script calculate drag items position -->
 | 
			
		||||
    <core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
 | 
			
		||||
 | 
			
		||||
    <ion-item class="ion-text-wrap" [hidden]="!ddQuestion.loaded">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
 | 
			
		||||
                <ion-item>
 | 
			
		||||
                    <ion-icon name="fas-info-circle" slot="start"></ion-icon>
 | 
			
		||||
                    <ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-card>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text" #questiontext
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
 | 
			
		||||
                (afterRender)="textRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
            <core-format-text *ngIf="ddQuestion.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId"
 | 
			
		||||
                [text]="ddQuestion.ddArea" [filter]="false" (afterRender)="ddAreaRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										150
									
								
								src/addons/qtype/ddmarker/component/ddmarker.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/addons/qtype/ddmarker/component/ddmarker.scss
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										188
									
								
								src/addons/qtype/ddmarker/component/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/addons/qtype/ddmarker/component/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,188 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreFilepool } from '@services/filepool';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreUrlUtils } from '@services/utils/url';
 | 
			
		||||
import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a drag-and-drop markers question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qtype-ddmarker',
 | 
			
		||||
    templateUrl: 'addon-qtype-ddmarker.html',
 | 
			
		||||
    styleUrls: ['ddmarker.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @ViewChild('questiontext') questionTextEl?: ElementRef;
 | 
			
		||||
 | 
			
		||||
    ddQuestion?: AddonQtypeDdMarkerQuestionData;
 | 
			
		||||
 | 
			
		||||
    protected questionInstance?: AddonQtypeDdMarkerQuestion;
 | 
			
		||||
    protected dropZones: unknown[] = []; // The drop zones received in the init object of the question.
 | 
			
		||||
    protected imgSrc?: string; // Background image URL.
 | 
			
		||||
    protected destroyed = false;
 | 
			
		||||
    protected textIsRendered = false;
 | 
			
		||||
    protected ddAreaisRendered = false;
 | 
			
		||||
 | 
			
		||||
    constructor(elementRef: ElementRef) {
 | 
			
		||||
        super('AddonQtypeDdMarkerComponent', elementRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        if (!this.question) {
 | 
			
		||||
            this.logger.warn('Aborting because of no question received.');
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion = this.question;
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(this.question.html);
 | 
			
		||||
 | 
			
		||||
        // Get D&D area, form and question text.
 | 
			
		||||
        const ddArea = element.querySelector('.ddarea');
 | 
			
		||||
        const ddForm = element.querySelector('.ddform');
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
 | 
			
		||||
        if (!ddArea || !ddForm || typeof this.ddQuestion.text == 'undefined') {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Build the D&D area HTML.
 | 
			
		||||
        this.ddQuestion.ddArea = ddArea.outerHTML;
 | 
			
		||||
 | 
			
		||||
        const wrongParts = element.querySelector('.wrongparts');
 | 
			
		||||
        if (wrongParts) {
 | 
			
		||||
            this.ddQuestion.ddArea += wrongParts.outerHTML;
 | 
			
		||||
        }
 | 
			
		||||
        this.ddQuestion.ddArea += ddForm.outerHTML;
 | 
			
		||||
        this.ddQuestion.readOnly = false;
 | 
			
		||||
 | 
			
		||||
        if (this.ddQuestion.initObjects) {
 | 
			
		||||
            // Moodle version <= 3.5.
 | 
			
		||||
            if (typeof this.ddQuestion.initObjects.dropzones != 'undefined') {
 | 
			
		||||
                this.dropZones = <unknown[]> this.ddQuestion.initObjects.dropzones;
 | 
			
		||||
            }
 | 
			
		||||
            if (typeof this.ddQuestion.initObjects.readonly != 'undefined') {
 | 
			
		||||
                this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly;
 | 
			
		||||
            }
 | 
			
		||||
        } else if (this.ddQuestion.amdArgs) {
 | 
			
		||||
            // Moodle version >= 3.6.
 | 
			
		||||
            let nextIndex = 1;
 | 
			
		||||
            // Moodle version >= 3.9, imgSrc is not specified, do not advance index.
 | 
			
		||||
            if (this.ddQuestion.amdArgs[nextIndex] !== undefined && typeof this.ddQuestion.amdArgs[nextIndex] != 'boolean') {
 | 
			
		||||
                this.imgSrc = <string> this.ddQuestion.amdArgs[nextIndex];
 | 
			
		||||
                nextIndex++;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') {
 | 
			
		||||
                this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[nextIndex];
 | 
			
		||||
            }
 | 
			
		||||
            nextIndex++;
 | 
			
		||||
 | 
			
		||||
            if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') {
 | 
			
		||||
                this.dropZones = <unknown[]> this.ddQuestion.amdArgs[nextIndex];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.loaded = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question ddArea has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    ddAreaRendered(): void {
 | 
			
		||||
        this.ddAreaisRendered = true;
 | 
			
		||||
        if (this.textIsRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question text has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    textRendered(): void {
 | 
			
		||||
        this.textIsRendered = true;
 | 
			
		||||
        if (this.ddAreaisRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    protected async questionRendered(): Promise<void> {
 | 
			
		||||
        if (this.destroyed) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        // Download background image (3.6+ sites).
 | 
			
		||||
        let imgSrc = this.imgSrc;
 | 
			
		||||
        const site = CoreSites.instance.getCurrentSite();
 | 
			
		||||
 | 
			
		||||
        if (this.imgSrc && site?.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(this.imgSrc)) {
 | 
			
		||||
            imgSrc = await CoreFilepool.instance.getSrcByUrl(
 | 
			
		||||
                site.id!,
 | 
			
		||||
                this.imgSrc,
 | 
			
		||||
                this.component,
 | 
			
		||||
                this.componentId,
 | 
			
		||||
                0,
 | 
			
		||||
                true,
 | 
			
		||||
                true,
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.questionTextEl) {
 | 
			
		||||
            await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create the instance.
 | 
			
		||||
        this.questionInstance = new AddonQtypeDdMarkerQuestion(
 | 
			
		||||
            this.hostElement,
 | 
			
		||||
            this.ddQuestion!,
 | 
			
		||||
            !!this.ddQuestion!.readOnly,
 | 
			
		||||
            this.dropZones,
 | 
			
		||||
            imgSrc,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.destroyed = true;
 | 
			
		||||
        this.questionInstance?.destroy();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for DD Marker question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonQtypeDdMarkerQuestionData = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    loaded?: boolean;
 | 
			
		||||
    readOnly?: boolean;
 | 
			
		||||
    ddArea?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/ddmarker/ddmarker.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/ddmarker/ddmarker.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										155
									
								
								src/addons/qtype/ddmarker/services/handlers/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								src/addons/qtype/ddmarker/services/handlers/ddmarker.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,155 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreQuestionHelper, CoreQuestionQuestion } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeDdMarkerComponent } from '../../component/ddmarker';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support drag-and-drop markers question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeDdMarkerHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeDdMarker';
 | 
			
		||||
    type = 'qtype_ddmarker';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeDdMarkerComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): number {
 | 
			
		||||
        // If 1 dragitem is set we assume the answer is complete (like Moodle does).
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            if (answers[name]) {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        return this.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the list of files that needs to be downloaded in addition to the files embedded in the HTML.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param usageId Usage ID.
 | 
			
		||||
     * @return List of files or URLs.
 | 
			
		||||
     */
 | 
			
		||||
    getAdditionalDownloadableFiles(question: CoreQuestionQuestionParsed, usageId?: number): CoreWSExternalFile[] {
 | 
			
		||||
        const treatedQuestion: CoreQuestionQuestion = question;
 | 
			
		||||
 | 
			
		||||
        CoreQuestionHelper.instance.extractQuestionScripts(treatedQuestion, usageId);
 | 
			
		||||
 | 
			
		||||
        if (treatedQuestion.amdArgs && typeof treatedQuestion.amdArgs[1] == 'string') {
 | 
			
		||||
            // Moodle 3.6+.
 | 
			
		||||
            return [{
 | 
			
		||||
                fileurl: treatedQuestion.amdArgs[1],
 | 
			
		||||
            }];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeDdMarkerHandler extends makeSingleton(AddonQtypeDdMarkerHandlerService) {}
 | 
			
		||||
							
								
								
									
										585
									
								
								src/addons/qtype/ddwtos/classes/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										585
									
								
								src/addons/qtype/ddwtos/classes/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,585 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { AddonModQuizDdwtosQuestionData } from '../component/ddwtos';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class to make a question of ddwtos type work.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdwtosQuestion {
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
    protected nextDragItemNo = 1;
 | 
			
		||||
    protected selectors!: AddonQtypeDdwtosQuestionCSSSelectors; // Result of cssSelectors.
 | 
			
		||||
    protected placed: {[no: number]: number} = {}; // Map that relates drag elements numbers with drop zones numbers.
 | 
			
		||||
    protected selected?: HTMLElement; // Selected element (being "dragged").
 | 
			
		||||
    protected resizeFunction?: () => void;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create the instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param logger Logger provider.
 | 
			
		||||
     * @param domUtils Dom Utils provider.
 | 
			
		||||
     * @param container The container HTMLElement of the question.
 | 
			
		||||
     * @param question The question instance.
 | 
			
		||||
     * @param readOnly Whether it's read only.
 | 
			
		||||
     * @param inputIds Ids of the inputs of the question (where the answers will be stored).
 | 
			
		||||
     */
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected container: HTMLElement,
 | 
			
		||||
        protected question: AddonModQuizDdwtosQuestionData,
 | 
			
		||||
        protected readOnly: boolean,
 | 
			
		||||
        protected inputIds: string[],
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonQtypeDdwtosQuestion');
 | 
			
		||||
 | 
			
		||||
        this.initializer();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clone a drag item and add it to the drag container.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dragHome Item to clone
 | 
			
		||||
     */
 | 
			
		||||
    cloneDragItem(dragHome: HTMLElement): void {
 | 
			
		||||
        const drag = <HTMLElement> dragHome.cloneNode(true);
 | 
			
		||||
 | 
			
		||||
        drag.classList.remove('draghome');
 | 
			
		||||
        drag.classList.add('drag');
 | 
			
		||||
        drag.classList.add('no' + this.nextDragItemNo);
 | 
			
		||||
        this.nextDragItemNo++;
 | 
			
		||||
        drag.setAttribute('tabindex', '0');
 | 
			
		||||
 | 
			
		||||
        drag.style.visibility = 'visible';
 | 
			
		||||
        drag.style.position = 'absolute';
 | 
			
		||||
 | 
			
		||||
        const container = this.container.querySelector(this.selectors.dragContainer());
 | 
			
		||||
        container?.appendChild(drag);
 | 
			
		||||
 | 
			
		||||
        if (!this.readOnly) {
 | 
			
		||||
            this.makeDraggable(drag);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clone the 'drag homes'.
 | 
			
		||||
     * Invisible 'drag homes' are output in the question. These have the same properties as the drag items but are invisible.
 | 
			
		||||
     * We clone these invisible elements to make the actual drag items.
 | 
			
		||||
     */
 | 
			
		||||
    cloneDragItems(): void {
 | 
			
		||||
        const dragHomes = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.dragHomes()));
 | 
			
		||||
        for (let x = 0; x < dragHomes.length; x++) {
 | 
			
		||||
            this.cloneDragItemsForOneChoice(dragHomes[x]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clone a certain 'drag home'. If it's an "infinite" drag, clone it several times.
 | 
			
		||||
     *
 | 
			
		||||
     * @param dragHome Element to clone.
 | 
			
		||||
     */
 | 
			
		||||
    cloneDragItemsForOneChoice(dragHome: HTMLElement): void {
 | 
			
		||||
        if (dragHome.classList.contains('infinite')) {
 | 
			
		||||
            const groupNo = this.getGroup(dragHome) ?? -1;
 | 
			
		||||
            const noOfDrags = this.container.querySelectorAll(this.selectors.dropsInGroup(groupNo)).length;
 | 
			
		||||
 | 
			
		||||
            for (let x = 0; x < noOfDrags; x++) {
 | 
			
		||||
                this.cloneDragItem(dragHome);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            this.cloneDragItem(dragHome);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Deselect all drags.
 | 
			
		||||
     */
 | 
			
		||||
    deselectDrags(): void {
 | 
			
		||||
        // Remove the selected class from all drags.
 | 
			
		||||
        const drags = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.drags()));
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            drag.classList.remove('selected');
 | 
			
		||||
        });
 | 
			
		||||
        this.selected = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function to call when the instance is no longer needed.
 | 
			
		||||
     */
 | 
			
		||||
    destroy(): void {
 | 
			
		||||
        if (this.resizeFunction) {
 | 
			
		||||
            window.removeEventListener('resize', this.resizeFunction);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the choice number of an element. It is extracted from the classes.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Choice number.
 | 
			
		||||
     */
 | 
			
		||||
    getChoice(node: HTMLElement | null): number | undefined {
 | 
			
		||||
        return this.getClassnameNumericSuffix(node, 'choice');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the number in a certain class name of an element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node The element to check.
 | 
			
		||||
     * @param prefix Prefix of the class to check.
 | 
			
		||||
     * @return The number in the class.
 | 
			
		||||
     */
 | 
			
		||||
    getClassnameNumericSuffix(node: HTMLElement | null, prefix: string): number | undefined {
 | 
			
		||||
        if (node?.classList.length) {
 | 
			
		||||
            const patt1 = new RegExp('^' + prefix + '([0-9])+$');
 | 
			
		||||
            const patt2 = new RegExp('([0-9])+$');
 | 
			
		||||
 | 
			
		||||
            for (let index = 0; index < node.classList.length; index++) {
 | 
			
		||||
                if (patt1.test(node.classList[index])) {
 | 
			
		||||
                    const match = patt2.exec(node.classList[index]);
 | 
			
		||||
 | 
			
		||||
                    return Number(match?.[0]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.logger.warn('Prefix "' + prefix + '" not found in class names.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the group number of an element. It is extracted from the classes.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Group number.
 | 
			
		||||
     */
 | 
			
		||||
    getGroup(node: HTMLElement | null): number | undefined {
 | 
			
		||||
        return this.getClassnameNumericSuffix(node, 'group');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the number of an element ('no'). It is extracted from the classes.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Number.
 | 
			
		||||
     */
 | 
			
		||||
    getNo(node: HTMLElement | null): number | undefined {
 | 
			
		||||
        return this.getClassnameNumericSuffix(node, 'no');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the place number of an element. It is extracted from the classes.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element to check.
 | 
			
		||||
     * @return Place number.
 | 
			
		||||
     */
 | 
			
		||||
    getPlace(node: HTMLElement | null): number | undefined {
 | 
			
		||||
        return this.getClassnameNumericSuffix(node, 'place');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the question.
 | 
			
		||||
     */
 | 
			
		||||
    async initializer(): Promise<void> {
 | 
			
		||||
        this.selectors = new AddonQtypeDdwtosQuestionCSSSelectors();
 | 
			
		||||
 | 
			
		||||
        const container = <HTMLElement> this.container.querySelector(this.selectors.topNode());
 | 
			
		||||
        if (this.readOnly) {
 | 
			
		||||
            container.classList.add('readonly');
 | 
			
		||||
        } else {
 | 
			
		||||
            container.classList.add('notreadonly');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Wait for the elements to be ready.
 | 
			
		||||
        await this.waitForReady();
 | 
			
		||||
 | 
			
		||||
        this.setPaddingSizesAll();
 | 
			
		||||
        this.cloneDragItems();
 | 
			
		||||
        this.initialPlaceOfDragItems();
 | 
			
		||||
        this.makeDropZones();
 | 
			
		||||
 | 
			
		||||
        this.positionDragItems();
 | 
			
		||||
 | 
			
		||||
        this.resizeFunction = this.positionDragItems.bind(this);
 | 
			
		||||
        window.addEventListener('resize', this.resizeFunction!);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize drag items, putting them in their initial place.
 | 
			
		||||
     */
 | 
			
		||||
    initialPlaceOfDragItems(): void {
 | 
			
		||||
        const drags = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.drags()));
 | 
			
		||||
 | 
			
		||||
        // Add the class 'unplaced' to all elements.
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            drag.classList.add('unplaced');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.placed = {};
 | 
			
		||||
        for (const placeNo in this.inputIds) {
 | 
			
		||||
            const inputId = this.inputIds[placeNo];
 | 
			
		||||
            const inputNode = this.container.querySelector('input#' + inputId);
 | 
			
		||||
            const choiceNo = Number(inputNode?.getAttribute('value'));
 | 
			
		||||
 | 
			
		||||
            if (choiceNo !== 0 && !isNaN(choiceNo)) {
 | 
			
		||||
                const drop = this.container.querySelector<HTMLElement>(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1));
 | 
			
		||||
                const groupNo = this.getGroup(drop) ?? -1;
 | 
			
		||||
                const drag = this.container.querySelector<HTMLElement>(
 | 
			
		||||
                    this.selectors.unplacedDragsForChoiceInGroup(choiceNo, groupNo),
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                this.placeDragInDrop(drag, drop);
 | 
			
		||||
                this.positionDragItem(drag);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element.
 | 
			
		||||
     */
 | 
			
		||||
    makeDraggable(drag: HTMLElement): void {
 | 
			
		||||
        drag.addEventListener('click', () => {
 | 
			
		||||
            if (drag.classList.contains('selected')) {
 | 
			
		||||
                this.deselectDrags();
 | 
			
		||||
            } else {
 | 
			
		||||
                this.selectDrag(drag);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert an element into a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drop Element.
 | 
			
		||||
     */
 | 
			
		||||
    makeDropZone(drop: HTMLElement): void {
 | 
			
		||||
        drop.addEventListener('click', () => {
 | 
			
		||||
            const drag = this.selected;
 | 
			
		||||
            if (!drag) {
 | 
			
		||||
                // No element selected, nothing to do.
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Place it only if the same group is selected.
 | 
			
		||||
            if (this.getGroup(drag) === this.getGroup(drop)) {
 | 
			
		||||
                this.placeDragInDrop(drag, drop);
 | 
			
		||||
                this.deselectDrags();
 | 
			
		||||
                this.positionDragItem(drag);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create all drop zones.
 | 
			
		||||
     */
 | 
			
		||||
    makeDropZones(): void {
 | 
			
		||||
        if (this.readOnly) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create all the drop zones.
 | 
			
		||||
        const drops = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.drops()));
 | 
			
		||||
        drops.forEach((drop) => {
 | 
			
		||||
            this.makeDropZone(drop);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // If home answer zone is clicked, return drag home.
 | 
			
		||||
        const home = <HTMLElement> this.container.querySelector(this.selectors.topNode() + ' .answercontainer');
 | 
			
		||||
 | 
			
		||||
        home.addEventListener('click', () => {
 | 
			
		||||
            const drag = this.selected;
 | 
			
		||||
            if (!drag) {
 | 
			
		||||
                // No element selected, nothing to do.
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Not placed yet, deselect.
 | 
			
		||||
            if (drag.classList.contains('unplaced')) {
 | 
			
		||||
                this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Remove, deselect and move back home in this order.
 | 
			
		||||
            this.removeDragFromDrop(drag);
 | 
			
		||||
            this.deselectDrags();
 | 
			
		||||
            this.positionDragItem(drag);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the width and height of an element.
 | 
			
		||||
     *
 | 
			
		||||
     * @param node Element.
 | 
			
		||||
     * @param width Width to set.
 | 
			
		||||
     * @param height Height to set.
 | 
			
		||||
     */
 | 
			
		||||
    protected padToWidthHeight(node: HTMLElement, width: number, height: number): void {
 | 
			
		||||
        node.style.width = width + 'px';
 | 
			
		||||
        node.style.height = height + 'px';
 | 
			
		||||
        // Originally lineHeight was set as height to center the text but it comes on too height lines on multiline elements.
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Place a draggable element inside a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Draggable element.
 | 
			
		||||
     * @param drop Drop zone.
 | 
			
		||||
     */
 | 
			
		||||
    placeDragInDrop(drag: HTMLElement | null, drop: HTMLElement | null): void {
 | 
			
		||||
        if (!drop) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const placeNo = this.getPlace(drop) ?? -1;
 | 
			
		||||
        const inputId = this.inputIds[placeNo - 1];
 | 
			
		||||
        const inputNode = this.container.querySelector('input#' + inputId);
 | 
			
		||||
 | 
			
		||||
        // Set the value of the drag element in the input of the drop zone.
 | 
			
		||||
        if (drag !== null) {
 | 
			
		||||
            inputNode?.setAttribute('value', String(this.getChoice(drag)));
 | 
			
		||||
        } else {
 | 
			
		||||
            inputNode?.setAttribute('value', '0');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove the element from the "placed" map if it's there.
 | 
			
		||||
        for (const alreadyThereDragNo in this.placed) {
 | 
			
		||||
            if (this.placed[alreadyThereDragNo] === placeNo) {
 | 
			
		||||
                delete this.placed[alreadyThereDragNo];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (drag !== null) {
 | 
			
		||||
            // Add the element in the "placed" map.
 | 
			
		||||
            this.placed[this.getNo(drag) ?? -1] = placeNo;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Position a drag element in the right drop zone or in the home zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Drag element.
 | 
			
		||||
     */
 | 
			
		||||
    positionDragItem(drag: HTMLElement | null): void {
 | 
			
		||||
        if (!drag) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let position;
 | 
			
		||||
 | 
			
		||||
        const placeNo = this.placed[this.getNo(drag) ?? -1];
 | 
			
		||||
        if (!placeNo) {
 | 
			
		||||
            // Not placed, put it in home zone.
 | 
			
		||||
            const groupNo = this.getGroup(drag) ?? -1;
 | 
			
		||||
            const choiceNo = this.getChoice(drag) ?? -1;
 | 
			
		||||
 | 
			
		||||
            position = CoreDomUtils.instance.getElementXY(
 | 
			
		||||
                this.container,
 | 
			
		||||
                this.selectors.dragHome(groupNo, choiceNo),
 | 
			
		||||
                'answercontainer',
 | 
			
		||||
            );
 | 
			
		||||
            drag.classList.add('unplaced');
 | 
			
		||||
        } else {
 | 
			
		||||
            // Get the drop zone position.
 | 
			
		||||
            position = CoreDomUtils.instance.getElementXY(
 | 
			
		||||
                this.container,
 | 
			
		||||
                this.selectors.dropForPlace(placeNo),
 | 
			
		||||
                'addon-qtype-ddwtos-container',
 | 
			
		||||
            );
 | 
			
		||||
            drag.classList.remove('unplaced');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (position) {
 | 
			
		||||
            drag.style.left = position[0] + 'px';
 | 
			
		||||
            drag.style.top = position[1] + 'px';
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Postition, or reposition, all the drag items. They're placed in the right drop zone or in the home zone.
 | 
			
		||||
     */
 | 
			
		||||
    positionDragItems(): void {
 | 
			
		||||
        const drags = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.drags()));
 | 
			
		||||
        drags.forEach((drag) => {
 | 
			
		||||
            this.positionDragItem(drag);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Wait for the drag items to have an offsetParent. For some reason it takes a while.
 | 
			
		||||
     *
 | 
			
		||||
     * @param retries Number of times this has been retried.
 | 
			
		||||
     * @return Promise resolved when ready or if it took too long to load.
 | 
			
		||||
     */
 | 
			
		||||
    protected async waitForReady(retries: number = 0): Promise<void> {
 | 
			
		||||
        const drag = <HTMLElement | null> Array.from(this.container.querySelectorAll(this.selectors.drags()))[0];
 | 
			
		||||
        if (drag?.offsetParent || retries >= 10) {
 | 
			
		||||
            // Ready or too many retries, stop.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const deferred = CoreUtils.instance.promiseDefer<void>();
 | 
			
		||||
 | 
			
		||||
        setTimeout(async () => {
 | 
			
		||||
            try {
 | 
			
		||||
                await this.waitForReady(retries + 1);
 | 
			
		||||
            } finally {
 | 
			
		||||
                deferred.resolve();
 | 
			
		||||
            }
 | 
			
		||||
        }, 20);
 | 
			
		||||
 | 
			
		||||
        return deferred.promise;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a draggable element from a drop zone.
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag The draggable element.
 | 
			
		||||
     */
 | 
			
		||||
    removeDragFromDrop(drag: HTMLElement): void {
 | 
			
		||||
        const placeNo = this.placed[this.getNo(drag) ?? -1];
 | 
			
		||||
        const drop = <HTMLElement> this.container.querySelector(this.selectors.dropForPlace(placeNo));
 | 
			
		||||
 | 
			
		||||
        this.placeDragInDrop(null, drop);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Select a certain element as being "dragged".
 | 
			
		||||
     *
 | 
			
		||||
     * @param drag Element.
 | 
			
		||||
     */
 | 
			
		||||
    selectDrag(drag: HTMLElement): void {
 | 
			
		||||
        // Deselect previous drags, only 1 can be selected.
 | 
			
		||||
        this.deselectDrags();
 | 
			
		||||
 | 
			
		||||
        this.selected = drag;
 | 
			
		||||
        drag.classList.add('selected');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the padding size for all groups.
 | 
			
		||||
     */
 | 
			
		||||
    setPaddingSizesAll(): void {
 | 
			
		||||
        for (let groupNo = 1; groupNo <= 8; groupNo++) {
 | 
			
		||||
            this.setPaddingSizeForGroup(groupNo);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the padding size for a certain group.
 | 
			
		||||
     *
 | 
			
		||||
     * @param groupNo Group number.
 | 
			
		||||
     */
 | 
			
		||||
    setPaddingSizeForGroup(groupNo: number): void {
 | 
			
		||||
        const groupItems = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.dragHomesGroup(groupNo)));
 | 
			
		||||
 | 
			
		||||
        if (!groupItems.length) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let maxWidth = 0;
 | 
			
		||||
        let maxHeight = 0;
 | 
			
		||||
 | 
			
		||||
        // Find max height and width.
 | 
			
		||||
        groupItems.forEach((item) => {
 | 
			
		||||
            item.innerHTML = CoreTextUtils.instance.decodeHTML(item.innerHTML);
 | 
			
		||||
            maxWidth = Math.max(maxWidth, Math.ceil(item.offsetWidth));
 | 
			
		||||
            maxHeight = Math.max(maxHeight, Math.ceil(item.offsetHeight));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        maxWidth += 8;
 | 
			
		||||
        maxHeight += 5;
 | 
			
		||||
        groupItems.forEach((item) => {
 | 
			
		||||
            this.padToWidthHeight(item, maxWidth, maxHeight);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const dropsGroup = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.dropsGroup(groupNo)));
 | 
			
		||||
        dropsGroup.forEach((item) => {
 | 
			
		||||
            this.padToWidthHeight(item, maxWidth + 2, maxHeight + 2);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Set of functions to get the CSS selectors.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonQtypeDdwtosQuestionCSSSelectors {
 | 
			
		||||
 | 
			
		||||
    topNode(): string {
 | 
			
		||||
        return '.addon-qtype-ddwtos-container';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragContainer(): string {
 | 
			
		||||
        return this.topNode() + ' div.drags';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    drags(): string {
 | 
			
		||||
        return this.dragContainer() + ' span.drag';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    drag(no: number): string {
 | 
			
		||||
        return this.drags() + `.no${no}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragsInGroup(groupNo: number): string {
 | 
			
		||||
        return this.drags() + `.group${groupNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    unplacedDragsInGroup(groupNo: number): string {
 | 
			
		||||
        return this.dragsInGroup(groupNo) + '.unplaced';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragsForChoiceInGroup(choiceNo: number, groupNo: number): string {
 | 
			
		||||
        return this.dragsInGroup(groupNo) + `.choice${choiceNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    unplacedDragsForChoiceInGroup(choiceNo: number, groupNo: number): string {
 | 
			
		||||
        return this.unplacedDragsInGroup(groupNo) + `.choice${choiceNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    drops(): string {
 | 
			
		||||
        return this.topNode() + ' span.drop';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dropForPlace(placeNo: number): string {
 | 
			
		||||
        return this.drops() + `.place${placeNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dropsInGroup(groupNo: number): string {
 | 
			
		||||
        return this.drops() + `.group${groupNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragHomes(): string {
 | 
			
		||||
        return this.topNode() + ' span.draghome';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragHomesGroup(groupNo: number): string {
 | 
			
		||||
        return this.topNode() + ` .draggrouphomes${groupNo} span.draghome`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dragHome(groupNo: number, choiceNo: number): string {
 | 
			
		||||
        return this.topNode() + ` .draggrouphomes${groupNo} span.draghome.choice${choiceNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dropsGroup(groupNo: number): string {
 | 
			
		||||
        return this.topNode() + ` span.drop.group${groupNo}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddwtos-container">
 | 
			
		||||
    <!-- Content is outside the core-loading to let the script calculate drag items position -->
 | 
			
		||||
    <core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
 | 
			
		||||
 | 
			
		||||
    <ion-item class="ion-text-wrap addon-qtype-ddwtos-container" [hidden]="!ddQuestion.loaded">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
 | 
			
		||||
                <ion-item>
 | 
			
		||||
                    <ion-icon name="fas-info-circle" slot="start"></ion-icon>
 | 
			
		||||
                    <ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-card>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId" #questiontext
 | 
			
		||||
                (afterRender)="textRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
            <core-format-text *ngIf="ddQuestion.answers" [component]="component" [componentId]="componentId"
 | 
			
		||||
                [text]="ddQuestion.answers" [filter]="false" (afterRender)="answersRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
            <div class="drags"></div>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										132
									
								
								src/addons/qtype/ddwtos/component/ddwtos.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/addons/qtype/ddwtos/component/ddwtos.scss
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										167
									
								
								src/addons/qtype/ddwtos/component/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								src/addons/qtype/ddwtos/component/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,167 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { AddonQtypeDdwtosQuestion } from '../classes/ddwtos';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a drag-and-drop words into sentences question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qtype-ddwtos',
 | 
			
		||||
    templateUrl: 'addon-qtype-ddwtos.html',
 | 
			
		||||
    styleUrls: ['ddwtos.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @ViewChild('questiontext') questionTextEl?: ElementRef;
 | 
			
		||||
 | 
			
		||||
    ddQuestion?: AddonModQuizDdwtosQuestionData;
 | 
			
		||||
 | 
			
		||||
    protected questionInstance?: AddonQtypeDdwtosQuestion;
 | 
			
		||||
    protected inputIds: string[] = []; // Ids of the inputs of the question (where the answers will be stored).
 | 
			
		||||
    protected destroyed = false;
 | 
			
		||||
    protected textIsRendered = false;
 | 
			
		||||
    protected answerAreRendered = false;
 | 
			
		||||
 | 
			
		||||
    constructor(elementRef: ElementRef) {
 | 
			
		||||
        super('AddonQtypeDdwtosComponent', elementRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        if (!this.question) {
 | 
			
		||||
            this.logger.warn('Aborting because of no question received.');
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion = this.question;
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html);
 | 
			
		||||
 | 
			
		||||
        // Replace Moodle's correct/incorrect and feedback classes with our own.
 | 
			
		||||
        CoreQuestionHelper.instance.replaceCorrectnessClasses(element);
 | 
			
		||||
        CoreQuestionHelper.instance.replaceFeedbackClasses(element);
 | 
			
		||||
 | 
			
		||||
        // Treat the correct/incorrect icons.
 | 
			
		||||
        CoreQuestionHelper.instance.treatCorrectnessIcons(element);
 | 
			
		||||
 | 
			
		||||
        const answerContainer = element.querySelector('.answercontainer');
 | 
			
		||||
        if (!answerContainer) {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.readOnly = answerContainer.classList.contains('readonly');
 | 
			
		||||
        this.ddQuestion.answers = answerContainer.outerHTML;
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
 | 
			
		||||
        if (typeof this.ddQuestion.text == 'undefined') {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the inputs where the answers will be stored and add them to the question text.
 | 
			
		||||
        const inputEls = <HTMLElement[]> Array.from(element.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])'));
 | 
			
		||||
 | 
			
		||||
        inputEls.forEach((inputEl) => {
 | 
			
		||||
            this.ddQuestion!.text += inputEl.outerHTML;
 | 
			
		||||
            const id = inputEl.getAttribute('id');
 | 
			
		||||
            if (id) {
 | 
			
		||||
                this.inputIds.push(id);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion.loaded = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question answers have been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    answersRendered(): void {
 | 
			
		||||
        this.answerAreRendered = true;
 | 
			
		||||
        if (this.textIsRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question text has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    textRendered(): void {
 | 
			
		||||
        this.textIsRendered = true;
 | 
			
		||||
        if (this.answerAreRendered) {
 | 
			
		||||
            this.questionRendered();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The question has been rendered.
 | 
			
		||||
     */
 | 
			
		||||
    protected async questionRendered(): Promise<void> {
 | 
			
		||||
        if (this.destroyed) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.questionTextEl) {
 | 
			
		||||
            await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create the instance.
 | 
			
		||||
        this.questionInstance = new AddonQtypeDdwtosQuestion(
 | 
			
		||||
            this.hostElement,
 | 
			
		||||
            this.ddQuestion!,
 | 
			
		||||
            !!this.ddQuestion!.readOnly,
 | 
			
		||||
            this.inputIds,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        CoreQuestionHelper.instance.treatCorrectnessIconsClicks(
 | 
			
		||||
            this.hostElement,
 | 
			
		||||
            this.component,
 | 
			
		||||
            this.componentId,
 | 
			
		||||
            this.contextLevel,
 | 
			
		||||
            this.contextInstanceId,
 | 
			
		||||
            this.courseId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        this.ddQuestion!.loaded = true;
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.destroyed = true;
 | 
			
		||||
        this.questionInstance?.destroy();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for DD WtoS question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizDdwtosQuestionData = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    loaded?: boolean;
 | 
			
		||||
    readOnly?: boolean;
 | 
			
		||||
    answers?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/ddwtos/ddwtos.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/ddwtos/ddwtos.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										134
									
								
								src/addons/qtype/ddwtos/services/handlers/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/addons/qtype/ddwtos/services/handlers/ddwtos.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,134 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeDdwtosComponent } from '../../component/ddwtos';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support drag-and-drop words into sentences question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeDdwtosHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeDdwtos';
 | 
			
		||||
    type = 'qtype_ddwtos';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeDdwtosComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (!value || value === '0') {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (value && value !== '0') {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeDdwtosHandler extends makeSingleton(AddonQtypeDdwtosHandlerService) {}
 | 
			
		||||
@ -0,0 +1,12 @@
 | 
			
		||||
<ion-list *ngIf="question && (question.text || question.text === '')">
 | 
			
		||||
    <!-- "Seen" hidden input -->
 | 
			
		||||
    <input *ngIf="seenInput" type="hidden" [name]="seenInput.name" [value]="seenInput.value" >
 | 
			
		||||
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										53
									
								
								src/addons/qtype/description/component/description.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/addons/qtype/description/component/description.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,53 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, ElementRef } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a description question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qtype-description',
 | 
			
		||||
    templateUrl: 'addon-qtype-description.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    seenInput?: { name: string; value: string };
 | 
			
		||||
 | 
			
		||||
    constructor(elementRef: ElementRef) {
 | 
			
		||||
        super('AddonQtypeDescriptionComponent', elementRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        const questionEl = this.initComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the "seen" hidden input.
 | 
			
		||||
        const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=seen]');
 | 
			
		||||
        if (input) {
 | 
			
		||||
            this.seenInput = {
 | 
			
		||||
                name: input.name,
 | 
			
		||||
                value: input.value,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/description/description.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/description/description.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeDescriptionComponent } from './component/description';
 | 
			
		||||
import { AddonQtypeDescriptionHandler } from './services/handlers/description';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonQtypeDescriptionComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeDescriptionHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonQtypeDescriptionComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeDescriptionModule {}
 | 
			
		||||
@ -0,0 +1,77 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeDescriptionComponent } from '../../component/description';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support description question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeDescriptionHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeDescription';
 | 
			
		||||
    type = 'qtype_description';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(): string {
 | 
			
		||||
        return 'informationitem';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeDescriptionComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate if an offline sequencecheck is valid compared with the online one.
 | 
			
		||||
     * This function only needs to be implemented if a specific compare is required.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param offlineSequenceCheck Sequence check stored in offline.
 | 
			
		||||
     * @return Whether sequencecheck is valid.
 | 
			
		||||
     */
 | 
			
		||||
    validateSequenceCheck(): boolean {
 | 
			
		||||
        // Descriptions don't have any answer so we'll always treat them as valid.
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeDescriptionHandler extends makeSingleton(AddonQtypeDescriptionHandlerService) {}
 | 
			
		||||
							
								
								
									
										87
									
								
								src/addons/qtype/essay/component/addon-qtype-essay.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/addons/qtype/essay/component/addon-qtype-essay.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,87 @@
 | 
			
		||||
<ion-list *ngIf="essayQuestion && (essayQuestion.text || essayQuestion.text === '')">
 | 
			
		||||
    <!-- Question text. -->
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="essayQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
 | 
			
		||||
    <!-- Editing the question. -->
 | 
			
		||||
    <ng-container *ngIf="!review">
 | 
			
		||||
        <!-- Textarea. -->
 | 
			
		||||
        <ion-item *ngIf="essayQuestion.textarea && (!essayQuestion.hasDraftFiles || uploadFilesSupported)">
 | 
			
		||||
            <ion-label></ion-label>
 | 
			
		||||
            <!-- "Format" and draftid hidden inputs -->
 | 
			
		||||
            <input *ngIf="essayQuestion.formatInput" type="hidden" [name]="essayQuestion.formatInput.name"
 | 
			
		||||
                [value]="essayQuestion.formatInput.value" >
 | 
			
		||||
            <input *ngIf="essayQuestion.answerDraftIdInput" type="hidden" [name]="essayQuestion.answerDraftIdInput.name"
 | 
			
		||||
                [value]="essayQuestion.answerDraftIdInput.value" >
 | 
			
		||||
            <!-- Plain text textarea. -->
 | 
			
		||||
            <ion-textarea *ngIf="essayQuestion.isPlainText" class="core-question-textarea"
 | 
			
		||||
                [ngClass]='{"core-monospaced": essayQuestion.isMonospaced}'
 | 
			
		||||
                placeholder="{{ 'core.question.answer' | translate }}"
 | 
			
		||||
                [attr.name]="essayQuestion.textarea.name" aria-multiline="true" [ngModel]="essayQuestion.textarea.text">
 | 
			
		||||
            </ion-textarea>
 | 
			
		||||
            <!-- Rich text editor. -->
 | 
			
		||||
            <core-rich-text-editor *ngIf="!essayQuestion.isPlainText" placeholder="{{ 'core.question.answer' | translate }}"
 | 
			
		||||
                [control]="formControl" [name]="essayQuestion.textarea.name" [component]="component" [componentId]="componentId"
 | 
			
		||||
                [autoSave]="false">
 | 
			
		||||
            </core-rich-text-editor>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
 | 
			
		||||
        <!-- Draft files not supported. -->
 | 
			
		||||
        <ng-container *ngIf="essayQuestion.textarea && essayQuestion.hasDraftFiles && !uploadFilesSupported">
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item">
 | 
			
		||||
                <ion-label class="core-question-warning">
 | 
			
		||||
                    {{ 'core.question.errorembeddedfilesnotsupportedinsite' | translate }}
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <core-format-text [component]="component" [componentId]="componentId" [text]="essayQuestion.textarea.text"
 | 
			
		||||
                        [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                    </core-format-text>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
 | 
			
		||||
        <!-- Attachments. -->
 | 
			
		||||
        <ng-container *ngIf="essayQuestion.allowsAttachments">
 | 
			
		||||
            <core-attachments *ngIf="uploadFilesSupported && essayQuestion.attachmentsDraftIdInput" [files]="attachments"
 | 
			
		||||
                [component]="component" [componentId]="componentId" [maxSize]="essayQuestion.attachmentsMaxBytes"
 | 
			
		||||
                [maxSubmissions]="essayQuestion.attachmentsMaxFiles" [allowOffline]="offlineEnabled"
 | 
			
		||||
                [acceptedTypes]="essayQuestion.attachmentsAcceptedTypes">
 | 
			
		||||
            </core-attachments>
 | 
			
		||||
 | 
			
		||||
            <input *ngIf="essayQuestion.attachmentsDraftIdInput" type="hidden" [name]="essayQuestion.attachmentsDraftIdInput.name"
 | 
			
		||||
                [value]="essayQuestion.attachmentsDraftIdInput.value" >
 | 
			
		||||
 | 
			
		||||
            <!-- Attachments not supported in this site. -->
 | 
			
		||||
            <ion-item *ngIf="!uploadFilesSupported" class="ion-text-wrap core-danger-item">
 | 
			
		||||
                <ion-label class="core-question-warning">
 | 
			
		||||
                    {{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
    <!-- Reviewing the question. -->
 | 
			
		||||
    <ng-container *ngIf="review">
 | 
			
		||||
        <!-- Answer to the question and attachments (reviewing). -->
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngIf="essayQuestion.answer || essayQuestion.answer == ''">
 | 
			
		||||
            <ion-label>
 | 
			
		||||
                <core-format-text [ngClass]='{"core-monospaced": essayQuestion.isMonospaced}' [component]="component"
 | 
			
		||||
                    [componentId]="componentId" [text]="essayQuestion.answer" [contextLevel]="contextLevel"
 | 
			
		||||
                    [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                </core-format-text>
 | 
			
		||||
            </ion-label>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
 | 
			
		||||
        <!-- List of attachments when reviewing. -->
 | 
			
		||||
        <core-files *ngIf="essayQuestion.attachments" [files]="essayQuestion.attachments" [component]="component"
 | 
			
		||||
            [componentId]="componentId">
 | 
			
		||||
        </core-files>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										96
									
								
								src/addons/qtype/essay/component/essay.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/addons/qtype/essay/component/essay.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,96 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, ElementRef } from '@angular/core';
 | 
			
		||||
import { FormBuilder, FormControl } from '@angular/forms';
 | 
			
		||||
import { FileEntry } from '@ionic-native/file/ngx';
 | 
			
		||||
 | 
			
		||||
import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
 | 
			
		||||
import { AddonModQuizEssayQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { CoreFileSession } from '@services/file-session';
 | 
			
		||||
import { CoreQuestion } from '@features/question/services/question';
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render an essay question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qtype-essay',
 | 
			
		||||
    templateUrl: 'addon-qtype-essay.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    formControl?: FormControl;
 | 
			
		||||
    attachments?: (CoreWSExternalFile | FileEntry)[];
 | 
			
		||||
    uploadFilesSupported = false;
 | 
			
		||||
    essayQuestion?: AddonModQuizEssayQuestion;
 | 
			
		||||
 | 
			
		||||
    constructor(elementRef: ElementRef, protected fb: FormBuilder) {
 | 
			
		||||
        super('AddonQtypeEssayComponent', elementRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.uploadFilesSupported = typeof this.question?.responsefileareas != 'undefined';
 | 
			
		||||
        this.initEssayComponent(this.review);
 | 
			
		||||
        this.essayQuestion = this.question;
 | 
			
		||||
 | 
			
		||||
        this.formControl = this.fb.control(this.essayQuestion?.textarea?.text);
 | 
			
		||||
 | 
			
		||||
        if (this.essayQuestion?.allowsAttachments && this.uploadFilesSupported && !this.review) {
 | 
			
		||||
            this.loadAttachments();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load attachments.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadAttachments(): Promise<void> {
 | 
			
		||||
        if (this.offlineEnabled && this.essayQuestion?.localAnswers?.attachments_offline) {
 | 
			
		||||
 | 
			
		||||
            const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON(
 | 
			
		||||
                this.essayQuestion.localAnswers.attachments_offline,
 | 
			
		||||
                {
 | 
			
		||||
                    online: [],
 | 
			
		||||
                    offline: 0,
 | 
			
		||||
                },
 | 
			
		||||
            );
 | 
			
		||||
            let offlineFiles: FileEntry[] = [];
 | 
			
		||||
 | 
			
		||||
            if (attachmentsData.offline) {
 | 
			
		||||
                offlineFiles = <FileEntry[]> await CoreQuestionHelper.instance.getStoredQuestionFiles(
 | 
			
		||||
                    this.essayQuestion,
 | 
			
		||||
                    this.component || '',
 | 
			
		||||
                    this.componentId || -1,
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.attachments = [...attachmentsData.online, ...offlineFiles];
 | 
			
		||||
        } else {
 | 
			
		||||
            this.attachments = Array.from(CoreQuestionHelper.instance.getResponseFileAreaFiles(this.question!, 'attachments'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        CoreFileSession.instance.setFiles(
 | 
			
		||||
            this.component || '',
 | 
			
		||||
            CoreQuestion.instance.getQuestionComponentId(this.question!, this.componentId || -1),
 | 
			
		||||
            this.attachments,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								src/addons/qtype/essay/essay.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/addons/qtype/essay/essay.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										464
									
								
								src/addons/qtype/essay/services/handlers/essay.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										464
									
								
								src/addons/qtype/essay/services/handlers/essay.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,464 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
import { FileEntry } from '@ionic-native/file/ngx';
 | 
			
		||||
 | 
			
		||||
import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
 | 
			
		||||
import { AddonModQuizEssayQuestion } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreFileSession } from '@services/file-session';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeEssayComponent } from '../../component/essay';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support essay question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeEssayHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeEssay';
 | 
			
		||||
    type = 'qtype_essay';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clear temporary data after the data has been saved.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     */
 | 
			
		||||
    clearTmpData(question: CoreQuestionQuestionParsed, component: string, componentId: string | number): void {
 | 
			
		||||
        const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
 | 
			
		||||
        const files = CoreFileSession.instance.getFiles(component, questionComponentId);
 | 
			
		||||
 | 
			
		||||
        // Clear the files in session for this question.
 | 
			
		||||
        CoreFileSession.instance.clearFiles(component, questionComponentId);
 | 
			
		||||
 | 
			
		||||
        // Now delete the local files from the tmp folder.
 | 
			
		||||
        CoreFileUploader.instance.clearTmpFiles(files);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Delete any stored data for the question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    deleteOfflineData(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        return CoreQuestionHelper.instance.deleteStoredQuestionFiles(question, component, componentId, siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the list of files that needs to be downloaded in addition to the files embedded in the HTML.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param usageId Usage ID.
 | 
			
		||||
     * @return List of files or URLs.
 | 
			
		||||
     */
 | 
			
		||||
    getAdditionalDownloadableFiles(question: CoreQuestionQuestionParsed): CoreWSExternalFile[] {
 | 
			
		||||
        if (!question.responsefileareas) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return question.responsefileareas.reduce((urlsList, area) => urlsList.concat(area.files || []), <CoreWSExternalFile[]> []);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check whether the question allows text and/or attachments.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question to check.
 | 
			
		||||
     * @return Allowed options.
 | 
			
		||||
     */
 | 
			
		||||
    protected getAllowedOptions(question: CoreQuestionQuestionParsed): { text: boolean; attachments: boolean } {
 | 
			
		||||
        if (question.parsedSettings) {
 | 
			
		||||
            return {
 | 
			
		||||
                text: question.parsedSettings.responseformat != 'noinline',
 | 
			
		||||
                attachments: question.parsedSettings.attachments != '0',
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            text: !!element.querySelector('textarea[name*=_answer]'),
 | 
			
		||||
            attachments: !!element.querySelector('div[id*=filemanager]'),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(): string {
 | 
			
		||||
        return 'manualgraded';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeEssayComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a question can be submitted.
 | 
			
		||||
     * If a question cannot be submitted it should return a message explaining why (translated or not).
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Prevent submit message. Undefined or empty if can be submitted.
 | 
			
		||||
     */
 | 
			
		||||
    getPreventSubmitMessage(question: CoreQuestionQuestionParsed): string | undefined {
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
        const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
 | 
			
		||||
 | 
			
		||||
        if (!uploadFilesSupported && element.querySelector('div[id*=filemanager]')) {
 | 
			
		||||
            // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question.
 | 
			
		||||
            return 'core.question.errorattachmentsnotsupportedinsite';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!uploadFilesSupported && CoreQuestionHelper.instance.hasDraftFileUrls(element.innerHTML)) {
 | 
			
		||||
            return 'core.question.errorembeddedfilesnotsupportedinsite';
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
 | 
			
		||||
        const hasTextAnswer = !!answers.answer;
 | 
			
		||||
        const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
 | 
			
		||||
        const allowedOptions = this.getAllowedOptions(question);
 | 
			
		||||
 | 
			
		||||
        if (!allowedOptions.attachments) {
 | 
			
		||||
            return hasTextAnswer ? 1 : 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!uploadFilesSupported || !question.parsedSettings) {
 | 
			
		||||
            // We can't know if the attachments are required or if the user added any in web.
 | 
			
		||||
            return -1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
 | 
			
		||||
        const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
 | 
			
		||||
 | 
			
		||||
        if (!allowedOptions.text) {
 | 
			
		||||
            return attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired) ? 1 : 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ((hasTextAnswer || question.parsedSettings.responserequired == '0') &&
 | 
			
		||||
                (attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired))) ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        if (typeof question.responsefileareas == 'undefined') {
 | 
			
		||||
            return -1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
 | 
			
		||||
        const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
 | 
			
		||||
 | 
			
		||||
        // Determine if the given response has online text or attachments.
 | 
			
		||||
        return (answers.answer && answers.answer !== '') || (attachments && attachments.length > 0) ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
 | 
			
		||||
        const allowedOptions = this.getAllowedOptions(question);
 | 
			
		||||
 | 
			
		||||
        // First check the inline text.
 | 
			
		||||
        const answerIsEqual = allowedOptions.text ?
 | 
			
		||||
            CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true;
 | 
			
		||||
 | 
			
		||||
        if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) {
 | 
			
		||||
            // No need to check attachments.
 | 
			
		||||
            return answerIsEqual;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check attachments now.
 | 
			
		||||
        const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
 | 
			
		||||
        const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
 | 
			
		||||
        const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
 | 
			
		||||
 | 
			
		||||
        return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare and add to answers the data to send to server based in the input.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
 | 
			
		||||
     * @param offline Whether the data should be saved in offline.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Return a promise resolved when done if async, void if sync.
 | 
			
		||||
     */
 | 
			
		||||
    async prepareAnswers(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        offline: boolean,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
        const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
 | 
			
		||||
 | 
			
		||||
        // Search the textarea to get its name.
 | 
			
		||||
        const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
 | 
			
		||||
 | 
			
		||||
        if (textarea && typeof answers[textarea.name] != 'undefined') {
 | 
			
		||||
            await this.prepareTextAnswer(question, answers, textarea, siteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (attachmentsInput) {
 | 
			
		||||
            await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare attachments.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
 | 
			
		||||
     * @param offline Whether the data should be saved in offline.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param attachmentsInput The HTML input containing the draft ID for attachments.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Return a promise resolved when done if async, void if sync.
 | 
			
		||||
     */
 | 
			
		||||
    async prepareAttachments(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        offline: boolean,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        attachmentsInput: HTMLInputElement,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        // Treat attachments if any.
 | 
			
		||||
        const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
 | 
			
		||||
        const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
 | 
			
		||||
        const draftId = Number(attachmentsInput.value);
 | 
			
		||||
 | 
			
		||||
        if (offline) {
 | 
			
		||||
            // Get the folder where to store the files.
 | 
			
		||||
            const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId);
 | 
			
		||||
 | 
			
		||||
            const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments);
 | 
			
		||||
 | 
			
		||||
            // Store the files in the answers.
 | 
			
		||||
            answers[attachmentsInput.name + '_offline'] = JSON.stringify(result);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Check if any attachment was deleted.
 | 
			
		||||
            const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
 | 
			
		||||
            const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachments);
 | 
			
		||||
 | 
			
		||||
            if (filesToDelete.length > 0) {
 | 
			
		||||
                // Delete files.
 | 
			
		||||
                await CoreFileUploader.instance.deleteDraftFiles(draftId, filesToDelete, siteId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare data to send when performing a synchronization.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers Answers of the question, without the prefix.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prepareSyncData(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
        const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
 | 
			
		||||
 | 
			
		||||
        if (attachmentsInput) {
 | 
			
		||||
            // Update the draft ID, the stored one could no longer be valid.
 | 
			
		||||
            answers.attachments = attachmentsInput.value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!answers || !answers.attachments_offline) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON(
 | 
			
		||||
            <string> answers.attachments_offline,
 | 
			
		||||
            {
 | 
			
		||||
                online: [],
 | 
			
		||||
                offline: 0,
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
        delete answers.attachments_offline;
 | 
			
		||||
 | 
			
		||||
        // Check if any attachment was deleted.
 | 
			
		||||
        const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
 | 
			
		||||
        const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachmentsData.online);
 | 
			
		||||
 | 
			
		||||
        if (filesToDelete.length > 0) {
 | 
			
		||||
            // Delete files.
 | 
			
		||||
            await CoreFileUploader.instance.deleteDraftFiles(Number(answers.attachments), filesToDelete, siteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!attachmentsData.offline) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Upload the offline files.
 | 
			
		||||
        const offlineFiles =
 | 
			
		||||
            <FileEntry[]> await CoreQuestionHelper.instance.getStoredQuestionFiles(question, component, componentId, siteId);
 | 
			
		||||
 | 
			
		||||
        await CoreFileUploader.instance.uploadFiles(
 | 
			
		||||
            Number(answers.attachments),
 | 
			
		||||
            [...attachmentsData.online, ...offlineFiles],
 | 
			
		||||
            siteId,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare the text answer.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
 | 
			
		||||
     * @param textarea The textarea HTML element of the question.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prepareTextAnswer(
 | 
			
		||||
        question: AddonModQuizEssayQuestion,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        textarea: HTMLTextAreaElement,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        if (CoreQuestionHelper.instance.hasDraftFileUrls(question.html) && question.responsefileareas) {
 | 
			
		||||
            // Restore draftfile URLs.
 | 
			
		||||
            const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
            answers[textarea.name] = CoreTextUtils.instance.restoreDraftfileUrls(
 | 
			
		||||
                site.getURL(),
 | 
			
		||||
                <string> answers[textarea.name],
 | 
			
		||||
                question.html,
 | 
			
		||||
                CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'),
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let isPlainText = false;
 | 
			
		||||
        if (question.isPlainText !== undefined) {
 | 
			
		||||
            isPlainText = question.isPlainText;
 | 
			
		||||
        } else if (question.parsedSettings) {
 | 
			
		||||
            isPlainText = question.parsedSettings.responseformat == 'monospaced' ||
 | 
			
		||||
                question.parsedSettings.responseformat == 'plain';
 | 
			
		||||
        } else {
 | 
			
		||||
            const questionEl = CoreDomUtils.instance.convertToElement(question.html);
 | 
			
		||||
            isPlainText = !!questionEl.querySelector('.qtype_essay_monospaced') || !!questionEl.querySelector('.qtype_essay_plain');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!isPlainText) {
 | 
			
		||||
            // Add some HTML to the text if needed.
 | 
			
		||||
            answers[textarea.name] = CoreTextUtils.instance.formatHtmlLines(<string> answers[textarea.name]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeEssayHandler extends makeSingleton(AddonQtypeEssayHandlerService) {}
 | 
			
		||||
@ -0,0 +1,10 @@
 | 
			
		||||
<ion-list class="addon-qtype-gapselect-container" *ngIf="question && (question.text || question.text === '')">
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
 | 
			
		||||
                (afterRender)="questionRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										23
									
								
								src/addons/qtype/gapselect/component/gapselect.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/addons/qtype/gapselect/component/gapselect.scss
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
        // }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								src/addons/qtype/gapselect/component/gapselect.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/addons/qtype/gapselect/component/gapselect.ts
									
									
									
									
									
										Normal file
									
								
							@ -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,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/gapselect/gapselect.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/gapselect/gapselect.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										136
									
								
								src/addons/qtype/gapselect/services/handlers/gapselect.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/addons/qtype/gapselect/services/handlers/gapselect.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,136 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeGapSelectComponent } from '../../component/gapselect';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support gapselect question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeGapSelectHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeGapSelect';
 | 
			
		||||
    type = 'qtype_gapselect';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeGapSelectComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // We should always get a value for each select so we can assume we receive all the possible answers.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (!value || value === '0') {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // We should always get a value for each select so we can assume we receive all the possible answers.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (value) {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeGapSelectHandler extends makeSingleton(AddonQtypeGapSelectHandlerService) {}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/addons/qtype/match/component/addon-qtype-match.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/addons/qtype/match/component/addon-qtype-match.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
			
		||||
<section ion-list class="addon-qtype-match-container" *ngIf="matchQuestion && matchQuestion.loaded">
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="matchQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
    <ion-item class="ion-text-wrap" *ngFor="let row of matchQuestion.rows">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text id="addon-qtype-match-question-{{row.id}}" [component]="component"
 | 
			
		||||
                [componentId]="componentId" [text]="row.text" [contextLevel]="contextLevel"
 | 
			
		||||
                [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
            <label class="accesshide" for="{{row.id}}" *ngIf="row.accessibilityLabel">
 | 
			
		||||
                {{ row.accessibilityLabel }}
 | 
			
		||||
            </label>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
        <ion-select id="{{row.id}}" [name]="row.name" [(ngModel)]="row.selected" interface="action-sheet"
 | 
			
		||||
            [attr.aria-labelledby]="'addon-qtype-match-question-' + row.id" [disabled]="row.disabled"
 | 
			
		||||
            [ngClass]="{'addon-qtype-match-correct': row.isCorrect === 1,'addon-qtype-match-incorrect': row.isCorrect === 0}">
 | 
			
		||||
            <ion-select-option *ngFor="let option of row.options" [value]="option.value">
 | 
			
		||||
                {{option.label}}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
        </ion-select>
 | 
			
		||||
        <ion-icon *ngIf="row.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success" slot="end"></ion-icon>
 | 
			
		||||
        <ion-icon *ngIf="row.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger" slot="end"></ion-icon>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</section>
 | 
			
		||||
							
								
								
									
										13
									
								
								src/addons/qtype/match/component/match.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/addons/qtype/match/component/match.scss
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/match/component/match.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/match/component/match.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/match/match.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/match/match.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										136
									
								
								src/addons/qtype/match/services/handlers/match.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/addons/qtype/match/services/handlers/match.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,136 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeMatchComponent } from '../../component/match';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support match question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeMatchHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeMatch';
 | 
			
		||||
    type = 'qtype_match';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeMatchComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // We should always get a value for each select so we can assume we receive all the possible answers.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (!value || value === '0') {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // We should always get a value for each select so we can assume we receive all the possible answers.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (value && value !== '0') {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeMatchHandler extends makeSingleton(AddonQtypeMatchHandlerService) {}
 | 
			
		||||
@ -0,0 +1,10 @@
 | 
			
		||||
<ion-list class="addon-qtype-multianswer-container" *ngIf="question && (question.text || question.text === '')">
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
 | 
			
		||||
                (afterRender)="questionRendered()">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										38
									
								
								src/addons/qtype/multianswer/component/multianswer.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/addons/qtype/multianswer/component/multianswer.scss
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								src/addons/qtype/multianswer/component/multianswer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/addons/qtype/multianswer/component/multianswer.ts
									
									
									
									
									
										Normal file
									
								
							@ -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,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/multianswer/multianswer.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/multianswer/multianswer.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										162
									
								
								src/addons/qtype/multianswer/services/handlers/multianswer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								src/addons/qtype/multianswer/services/handlers/multianswer.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,162 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeMultiAnswerComponent } from '../../component/multianswer';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support multianswer question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeMultiAnswerHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeMultiAnswer';
 | 
			
		||||
    type = 'qtype_multianswer';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the name of the behaviour to use for the question.
 | 
			
		||||
     * If the question should use the default behaviour you shouldn't implement this function.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param behaviour The default behaviour.
 | 
			
		||||
     * @return The behaviour to use.
 | 
			
		||||
     */
 | 
			
		||||
    getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string {
 | 
			
		||||
        if (behaviour === 'interactive') {
 | 
			
		||||
            return 'interactivecountback';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return behaviour;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeMultiAnswerComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // Get all the inputs in the question to check if they've all been answered.
 | 
			
		||||
        const names = CoreQuestion.instance.getBasicAnswers<boolean>(
 | 
			
		||||
            CoreQuestionHelper.instance.getAllInputNamesFromHtml(question.html || ''),
 | 
			
		||||
        );
 | 
			
		||||
        for (const name in names) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (!value) {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // We should always get a value for each select so we can assume we receive all the possible answers.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const value = answers[name];
 | 
			
		||||
            if (value || value === false) {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate if an offline sequencecheck is valid compared with the online one.
 | 
			
		||||
     * This function only needs to be implemented if a specific compare is required.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param offlineSequenceCheck Sequence check stored in offline.
 | 
			
		||||
     * @return Whether sequencecheck is valid.
 | 
			
		||||
     */
 | 
			
		||||
    validateSequenceCheck(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean {
 | 
			
		||||
        if (question.sequencecheck == Number(offlineSequenceCheck)) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // For some reason, viewing a multianswer for the first time without answering it creates a new step "todo".
 | 
			
		||||
        // We'll treat this case as valid.
 | 
			
		||||
        if (question.sequencecheck == 2 && question.state == 'todo' && offlineSequenceCheck == '1') {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeMultiAnswerHandler extends makeSingleton(AddonQtypeMultiAnswerHandlerService) {}
 | 
			
		||||
@ -0,0 +1,67 @@
 | 
			
		||||
<ion-list *ngIf="multiQuestion && (multiQuestion.text || multiQuestion.text === '')">
 | 
			
		||||
    <!-- Question text first. -->
 | 
			
		||||
    <ion-item class="ion-text-wrap">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <p><core-format-text [component]="component" [componentId]="componentId" [text]="multiQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text></p>
 | 
			
		||||
            <p *ngIf="multiQuestion.prompt">{{ multiQuestion.prompt }}</p>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
 | 
			
		||||
    <!-- Checkbox for multiple choice. -->
 | 
			
		||||
    <ng-container *ngIf="multiQuestion.multi">
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngFor="let option of multiQuestion.options">
 | 
			
		||||
            <ion-label [color]='(option.isCorrect === 1 ? "success": "") +  (option.isCorrect === 0 ? "danger": "")'>
 | 
			
		||||
                <core-format-text [component]="component" [componentId]="componentId" [text]="option.text"
 | 
			
		||||
                    [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                </core-format-text>
 | 
			
		||||
                <div *ngIf="option.feedback" class="specificfeedback">
 | 
			
		||||
                    <core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"
 | 
			
		||||
                        [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                    </core-format-text>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ion-label>
 | 
			
		||||
 | 
			
		||||
            <ion-checkbox slot="end" [attr.name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled"
 | 
			
		||||
                [color]='(option.isCorrect === 1 ? "success": "") + (option.isCorrect === 0 ? "danger": "")'>
 | 
			
		||||
            </ion-checkbox>
 | 
			
		||||
 | 
			
		||||
            <ion-icon *ngIf="option.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success"></ion-icon>
 | 
			
		||||
            <ion-icon *ngIf="option.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger"></ion-icon>
 | 
			
		||||
 | 
			
		||||
            <!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
 | 
			
		||||
            <input type="hidden" [ngModel]="option.checked" [attr.name]="option.name">
 | 
			
		||||
        </ion-item>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
    <!-- Radio buttons for single choice. -->
 | 
			
		||||
    <ion-radio-group *ngIf="!multiQuestion.multi" [(ngModel)]="multiQuestion.singleChoiceModel" [name]="multiQuestion.optionsName">
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngFor="let option of multiQuestion.options">
 | 
			
		||||
            <ion-label>
 | 
			
		||||
                <core-format-text [component]="component" [componentId]="componentId" [text]="option.text"
 | 
			
		||||
                    [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                </core-format-text>
 | 
			
		||||
                <div *ngIf="option.feedback" class="specificfeedback">
 | 
			
		||||
                    <core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"
 | 
			
		||||
                        [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
                    </core-format-text>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ion-label>
 | 
			
		||||
 | 
			
		||||
            <ion-radio [value]="option.value" [disabled]="option.disabled" slot="end"
 | 
			
		||||
                [color]='(option.isCorrect === 1 ? "success": "") + (option.isCorrect === 0 ? "danger": "")'>
 | 
			
		||||
            </ion-radio>
 | 
			
		||||
 | 
			
		||||
            <ion-icon *ngIf="option.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success"></ion-icon>
 | 
			
		||||
            <ion-icon *ngIf="option.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger"></ion-icon>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
        <ion-button *ngIf="!multiQuestion.disabled" class="ion-text-wrap ion-margin" expand="full" color="light"
 | 
			
		||||
            [disabled]="!multiQuestion.singleChoiceModel" (click)="clear()" type="reset">
 | 
			
		||||
            {{ 'addon.mod_quiz.clearchoice' | translate }}
 | 
			
		||||
        </ion-button>
 | 
			
		||||
 | 
			
		||||
        <!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
 | 
			
		||||
        <input type="hidden" [ngModel]="multiQuestion.singleChoiceModel" [attr.name]="multiQuestion.optionsName">
 | 
			
		||||
    </ion-radio-group>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										8
									
								
								src/addons/qtype/multichoice/component/multichoice.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/addons/qtype/multichoice/component/multichoice.scss
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										50
									
								
								src/addons/qtype/multichoice/component/multichoice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/addons/qtype/multichoice/component/multichoice.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/multichoice/multichoice.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/multichoice/multichoice.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										201
									
								
								src/addons/qtype/multichoice/services/handlers/multichoice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								src/addons/qtype/multichoice/services/handlers/multichoice.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,201 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeMultichoiceComponent } from '../../component/multichoice';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support multichoice question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeMultichoiceHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeMultichoice';
 | 
			
		||||
    type = 'qtype_multichoice';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeMultichoiceComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): number {
 | 
			
		||||
        let isSingle = true;
 | 
			
		||||
        let isMultiComplete = false;
 | 
			
		||||
 | 
			
		||||
        // To know if it's single or multi answer we need to search for answers with "choice" in the name.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            if (name.indexOf('choice') != -1) {
 | 
			
		||||
                isSingle = false;
 | 
			
		||||
                if (answers[name]) {
 | 
			
		||||
                    isMultiComplete = true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isSingle) {
 | 
			
		||||
            // Single.
 | 
			
		||||
            return this.isCompleteResponseSingle(answers);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Multi.
 | 
			
		||||
            return isMultiComplete ? 1 : 0;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete. Only for single answer.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.uestion answers (without prefix).
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponseSingle(answers: CoreQuestionsAnswers): number {
 | 
			
		||||
        return (answers.answer && answers.answer !== '') ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        return this.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted. Only for single answer.
 | 
			
		||||
     *
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponseSingle(answers: CoreQuestionsAnswers): number {
 | 
			
		||||
        return this.isCompleteResponseSingle(answers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        let isSingle = true;
 | 
			
		||||
        let isMultiSame = true;
 | 
			
		||||
 | 
			
		||||
        // To know if it's single or multi answer we need to search for answers with "choice" in the name.
 | 
			
		||||
        for (const name in newAnswers) {
 | 
			
		||||
            if (name.indexOf('choice') != -1) {
 | 
			
		||||
                isSingle = false;
 | 
			
		||||
                if (!CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, name)) {
 | 
			
		||||
                    isMultiSame = false;
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isSingle) {
 | 
			
		||||
            return this.isSameResponseSingle(prevAnswers, newAnswers);
 | 
			
		||||
        } else {
 | 
			
		||||
            return isMultiSame;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same. Only for single answer.
 | 
			
		||||
     *
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponseSingle(prevAnswers: CoreQuestionsAnswers, newAnswers: CoreQuestionsAnswers): boolean {
 | 
			
		||||
        return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare and add to answers the data to send to server based in the input. Return promise if async.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
 | 
			
		||||
     * @param offline Whether the data should be saved in offline.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Return a promise resolved when done if async, void if sync.
 | 
			
		||||
     */
 | 
			
		||||
    prepareAnswers(
 | 
			
		||||
        question: AddonModQuizMultichoiceQuestion,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): void {
 | 
			
		||||
        if (question && !question.multi && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) {
 | 
			
		||||
            /* It's a single choice and the user hasn't answered. Delete the answer because
 | 
			
		||||
               sending an empty string (default value) will mark the first option as selected. */
 | 
			
		||||
            delete answers[question.optionsName!];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeMultichoiceHandler extends makeSingleton(AddonQtypeMultichoiceHandlerService) {}
 | 
			
		||||
							
								
								
									
										35
									
								
								src/addons/qtype/numerical/numerical.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/addons/qtype/numerical/numerical.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										32
									
								
								src/addons/qtype/numerical/services/handlers/numerical.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/addons/qtype/numerical/services/handlers/numerical.ts
									
									
									
									
									
										Normal file
									
								
							@ -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) {}
 | 
			
		||||
							
								
								
									
										57
									
								
								src/addons/qtype/qtype.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/addons/qtype/qtype.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 { }
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/qtype/randomsamatch/randomsamatch.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/qtype/randomsamatch/randomsamatch.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeRandomSaMatchHandler } from './services/handlers/randomsamatch';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeRandomSaMatchHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeRandomSaMatchModule {}
 | 
			
		||||
@ -0,0 +1,31 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonQtypeMatchHandlerService } from '@addons/qtype/match/services/handlers/match';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support random short-answer matching question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeRandomSaMatchHandlerService extends AddonQtypeMatchHandlerService {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeRandomSaMatch';
 | 
			
		||||
    type = 'qtype_randomsamatch';
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeRandomSaMatchHandler extends makeSingleton(AddonQtypeRandomSaMatchHandlerService) {}
 | 
			
		||||
@ -0,0 +1,20 @@
 | 
			
		||||
<ion-list *ngIf="textQuestion && (textQuestion.text || textQuestion.text === '')">
 | 
			
		||||
    <ion-item class="ion-text-wrap addon-qtype-shortanswer-text">
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <core-format-text [component]="component" [componentId]="componentId" [text]="textQuestion.text"
 | 
			
		||||
                [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
    <ion-item *ngIf="textQuestion.input && !textQuestion.input.isInline"
 | 
			
		||||
        class="ion-text-wrap addon-qtype-shortanswer-input core-{{textQuestion.input.correctIconColor}}-item">
 | 
			
		||||
        <ion-label position="stacked">{{ 'addon.mod_quiz.answercolon' | translate }}</ion-label>
 | 
			
		||||
        <ion-input type="text" [placeholder]="textQuestion.input.readOnly ? '' : 'core.question.answer' | translate"
 | 
			
		||||
            [attr.name]="textQuestion.input.name" [value]="textQuestion.input.value" autocorrect="off"
 | 
			
		||||
            [disabled]="textQuestion.input.readOnly">
 | 
			
		||||
        </ion-input>
 | 
			
		||||
        <ion-icon *ngIf="textQuestion.input.correctIcon" class="core-correct-icon" slot="end"
 | 
			
		||||
            [name]="textQuestion.input.correctIcon" [color]="[textQuestion.input.correctIconColor]">
 | 
			
		||||
        </ion-icon>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</ion-list>
 | 
			
		||||
							
								
								
									
										5
									
								
								src/addons/qtype/shortanswer/component/shortanswer.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/addons/qtype/shortanswer/component/shortanswer.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
:host {
 | 
			
		||||
    .core-correct-icon {
 | 
			
		||||
        margin-top: 14px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/shortanswer/component/shortanswer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/shortanswer/component/shortanswer.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										109
									
								
								src/addons/qtype/shortanswer/services/handlers/shortanswer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/addons/qtype/shortanswer/services/handlers/shortanswer.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,109 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonQtypeShortAnswerComponent } from '../../component/shortanswer';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support short answer question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeShortAnswerHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeShortAnswer';
 | 
			
		||||
    type = 'qtype_shortanswer';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        return AddonQtypeShortAnswerComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): number {
 | 
			
		||||
        return answers.answer ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        return this.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeShortAnswerHandler extends makeSingleton(AddonQtypeShortAnswerHandlerService) {}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qtype/shortanswer/shortanswer.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qtype/shortanswer/shortanswer.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}
 | 
			
		||||
							
								
								
									
										132
									
								
								src/addons/qtype/truefalse/services/handlers/truefalse.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/addons/qtype/truefalse/services/handlers/truefalse.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,132 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice';
 | 
			
		||||
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support true/false question type.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQtypeTrueFalseHandlerService implements CoreQuestionHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQtypeTrueFalse';
 | 
			
		||||
    type = 'qtype_truefalse';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the question.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question to render.
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getComponent(): Type<unknown> {
 | 
			
		||||
        // True/false behaves like a multichoice, use the same component.
 | 
			
		||||
        return AddonQtypeMultichoiceComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): number {
 | 
			
		||||
        return answers.answer ? 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a student has provided enough of an answer for the question to be graded automatically,
 | 
			
		||||
     * or whether it must be considered aborted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    isGradableResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        return this.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare and add to answers the data to send to server based in the input. Return promise if async.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
 | 
			
		||||
     * @param offline Whether the data should be saved in offline.
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Return a promise resolved when done if async, void if sync.
 | 
			
		||||
     */
 | 
			
		||||
    prepareAnswers(
 | 
			
		||||
        question: AddonModQuizMultichoiceQuestion,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
    ): void | Promise<void> {
 | 
			
		||||
        if (question && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) {
 | 
			
		||||
            // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
 | 
			
		||||
            delete answers[question.optionsName!];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQtypeTrueFalseHandler extends makeSingleton(AddonQtypeTrueFalseHandlerService) {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/qtype/truefalse/truefalse.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/qtype/truefalse/truefalse.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { AddonQtypeTrueFalseHandler } from './services/handlers/truefalse';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionDelegate.instance.registerHandler(AddonQtypeTrueFalseHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQtypeTrueFalseModule {}
 | 
			
		||||
@ -31,6 +31,7 @@ import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { 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<boolean> {
 | 
			
		||||
        try {
 | 
			
		||||
            const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
            return this.canDeleteDraftFilesInSite(site);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a certain site allows deleting draft files.
 | 
			
		||||
     *
 | 
			
		||||
     * @param site Site. If not defined, use current site.
 | 
			
		||||
     * @return Whether draft files can be deleted.
 | 
			
		||||
     * @since 3.10
 | 
			
		||||
     */
 | 
			
		||||
    canDeleteDraftFilesInSite(site?: CoreSite): boolean {
 | 
			
		||||
        site = site || CoreSites.instance.getCurrentSite();
 | 
			
		||||
 | 
			
		||||
        return !!(site?.wsAvailable('core_files_delete_draft_files'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start the audio recorder application and return information about captured audio clip files.
 | 
			
		||||
     *
 | 
			
		||||
@ -175,6 +206,25 @@ export class CoreFileUploaderProvider {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Delete draft files.
 | 
			
		||||
     *
 | 
			
		||||
     * @param draftId Draft ID.
 | 
			
		||||
     * @param files Files to delete.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async deleteDraftFiles(draftId: number, files: { filepath: string; filename: string }[], siteId?: string): Promise<void> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const params = {
 | 
			
		||||
            draftitemid: draftId,
 | 
			
		||||
            files: files,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return site.write('core_files_delete_draft_files', params);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the upload options for a file taken with the Camera Cordova plugin.
 | 
			
		||||
     *
 | 
			
		||||
@ -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) => (<CoreWSExternalFile> currentFile).fileurl == file.fileurl);
 | 
			
		||||
 | 
			
		||||
            if (!stillInList) {
 | 
			
		||||
                filesToDelete.push({
 | 
			
		||||
                    filepath: file.filepath!,
 | 
			
		||||
                    filename: file.filename!,
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return filesToDelete;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the upload options for a file of any type.
 | 
			
		||||
     *
 | 
			
		||||
@ -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<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        if (!files || !files.length) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Index the online files by name.
 | 
			
		||||
        const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
 | 
			
		||||
        const filesToUpload: FileEntry[] = [];
 | 
			
		||||
        files.forEach((file) => {
 | 
			
		||||
            if (CoreUtils.instance.isFileEntry(file)) {
 | 
			
		||||
                filesToUpload.push(<FileEntry> file);
 | 
			
		||||
            } else {
 | 
			
		||||
                // It's an online file.
 | 
			
		||||
                usedNames[file.filename!.toLowerCase()] = file;
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await Promise.all(filesToUpload.map(async (file) => {
 | 
			
		||||
            // Make sure the file name is unique in the area.
 | 
			
		||||
            const name = CoreFile.instance.calculateUniqueName(usedNames, file.name);
 | 
			
		||||
            usedNames[name] = file;
 | 
			
		||||
 | 
			
		||||
            // Now upload the file.
 | 
			
		||||
            const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId);
 | 
			
		||||
 | 
			
		||||
            await this.uploadFile(file.toURL(), options, undefined, siteId);
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Upload a file to a draft area and return the draft ID.
 | 
			
		||||
     *
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										784
									
								
								src/core/features/question/classes/base-question-component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										784
									
								
								src/core/features/question/classes/base-question-component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,784 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Input, Output, EventEmitter, Component, Optional, Inject, ElementRef } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreUrlUtils } from '@services/utils/url';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } from '../services/question-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Base class for components to render a question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    template: '',
 | 
			
		||||
})
 | 
			
		||||
export class CoreQuestionBaseComponent {
 | 
			
		||||
 | 
			
		||||
    @Input() question?: AddonModQuizQuestion; // The question to render.
 | 
			
		||||
    @Input() component?: string; // The component the question belongs to.
 | 
			
		||||
    @Input() componentId?: number; // ID of the component the question belongs to.
 | 
			
		||||
    @Input() attemptId?: number; // Attempt ID.
 | 
			
		||||
    @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline.
 | 
			
		||||
    @Input() contextLevel?: string; // The context level.
 | 
			
		||||
    @Input() contextInstanceId?: number; // The instance ID related to the context.
 | 
			
		||||
    @Input() courseId?: number; // The course the question belongs to (if any).
 | 
			
		||||
    @Input() review?: boolean; // Whether the user is in review mode.
 | 
			
		||||
    @Output() buttonClicked = new EventEmitter<CoreQuestionBehaviourButton>(); // Will emit when a behaviour button is clicked.
 | 
			
		||||
    @Output() onAbort = new EventEmitter<void>(); // Should emit an event if the question should be aborted.
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
    protected hostElement: HTMLElement;
 | 
			
		||||
 | 
			
		||||
    constructor(@Optional() @Inject('') logName: string, elementRef: ElementRef) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance(logName);
 | 
			
		||||
        this.hostElement = elementRef.nativeElement;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component of type calculated or calculated simple.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initCalculatedComponent(): void | HTMLElement {
 | 
			
		||||
        // Treat the input text first.
 | 
			
		||||
        const questionEl = this.initInputTextComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if the question has a select for units.
 | 
			
		||||
        if (this.treatCalculatedSelectUnits(questionEl)) {
 | 
			
		||||
            return questionEl;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if the question has radio buttons for units.
 | 
			
		||||
        if (this.treatCalculatedRadioUnits(questionEl)) {
 | 
			
		||||
            return questionEl;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return questionEl;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Treat a calculated question units in case they use radio buttons.
 | 
			
		||||
     *
 | 
			
		||||
     * @param questionEl Question HTML element.
 | 
			
		||||
     * @return True if question has units using radio buttons.
 | 
			
		||||
     */
 | 
			
		||||
    protected treatCalculatedRadioUnits(questionEl: HTMLElement): boolean {
 | 
			
		||||
        // Check if the question has radio buttons for units.
 | 
			
		||||
        const radios = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
 | 
			
		||||
        if (!radios.length) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const question = <AddonModQuizCalculatedQuestion> this.question!;
 | 
			
		||||
        question.options = [];
 | 
			
		||||
 | 
			
		||||
        for (const i in radios) {
 | 
			
		||||
            const radioEl = radios[i];
 | 
			
		||||
            const option: AddonModQuizQuestionRadioOption = {
 | 
			
		||||
                id: radioEl.id,
 | 
			
		||||
                name: radioEl.name,
 | 
			
		||||
                value: radioEl.value,
 | 
			
		||||
                checked: radioEl.checked,
 | 
			
		||||
                disabled: radioEl.disabled,
 | 
			
		||||
            };
 | 
			
		||||
            // Get the label with the question text.
 | 
			
		||||
            const label = <HTMLElement> questionEl.querySelector('label[for="' + option.id + '"]');
 | 
			
		||||
 | 
			
		||||
            question.optionsName = option.name;
 | 
			
		||||
 | 
			
		||||
            if (!label || option.name === undefined || option.value === undefined) {
 | 
			
		||||
                // Something went wrong when extracting the questions data. Abort.
 | 
			
		||||
                this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
 | 
			
		||||
                CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            option.text = label.innerText;
 | 
			
		||||
            if (radioEl.checked) {
 | 
			
		||||
                // If the option is checked we use the model to select the one.
 | 
			
		||||
                question.unit = option.value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            question.options.push(option);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check which one should be displayed first: the options or the input.
 | 
			
		||||
        if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
 | 
			
		||||
            question.optionsFirst = question.parsedSettings.unitsleft == '1';
 | 
			
		||||
        } else {
 | 
			
		||||
            const input = questionEl.querySelector('input[type="text"][name*=answer]');
 | 
			
		||||
            question.optionsFirst =
 | 
			
		||||
                    questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(radios[0].outerHTML);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Treat a calculated question units in case they use a select.
 | 
			
		||||
     *
 | 
			
		||||
     * @param questionEl Question HTML element.
 | 
			
		||||
     * @return True if question has units using a select.
 | 
			
		||||
     */
 | 
			
		||||
    protected treatCalculatedSelectUnits(questionEl: HTMLElement): boolean {
 | 
			
		||||
        // Check if the question has a select for units.
 | 
			
		||||
        const select = <HTMLSelectElement> questionEl.querySelector('select[name*=unit]');
 | 
			
		||||
        const options = select && Array.from(select.querySelectorAll('option'));
 | 
			
		||||
 | 
			
		||||
        if (!select || !options?.length) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const question = <AddonModQuizCalculatedQuestion> this.question!;
 | 
			
		||||
        const selectModel: AddonModQuizQuestionSelect = {
 | 
			
		||||
            id: select.id,
 | 
			
		||||
            name: select.name,
 | 
			
		||||
            disabled: select.disabled,
 | 
			
		||||
            options: [],
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Treat each option.
 | 
			
		||||
        for (const i in options) {
 | 
			
		||||
            const optionEl = options[i];
 | 
			
		||||
 | 
			
		||||
            if (typeof optionEl.value == 'undefined') {
 | 
			
		||||
                this.logger.warn('Aborting because couldn\'t find input.', this.question?.slot);
 | 
			
		||||
                CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const option: AddonModQuizQuestionSelectOption = {
 | 
			
		||||
                value: optionEl.value,
 | 
			
		||||
                label: optionEl.innerHTML,
 | 
			
		||||
                selected: optionEl.selected,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            if (optionEl.selected) {
 | 
			
		||||
                selectModel.selected = option.value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            selectModel.options.push(option);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!selectModel.selected) {
 | 
			
		||||
            // No selected option, select the first one.
 | 
			
		||||
            selectModel.selected = selectModel.options[0].value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the accessibility label.
 | 
			
		||||
        const accessibilityLabel = questionEl.querySelector('label[for="' + select.id + '"]');
 | 
			
		||||
        selectModel.accessibilityLabel = accessibilityLabel?.innerHTML;
 | 
			
		||||
 | 
			
		||||
        question.select = selectModel;
 | 
			
		||||
 | 
			
		||||
        // Check which one should be displayed first: the select or the input.
 | 
			
		||||
        if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
 | 
			
		||||
            question.selectFirst = question.parsedSettings.unitsleft == '1';
 | 
			
		||||
        } else {
 | 
			
		||||
            const input = questionEl.querySelector('input[type="text"][name*=answer]');
 | 
			
		||||
            question.selectFirst =
 | 
			
		||||
                    questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(select.outerHTML);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the component and the question text.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initComponent(): void | HTMLElement {
 | 
			
		||||
        if (!this.question) {
 | 
			
		||||
            this.logger.warn('Aborting because of no question received.');
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.hostElement.classList.add('core-question-container');
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(this.question.html);
 | 
			
		||||
 | 
			
		||||
        // Extract question text.
 | 
			
		||||
        this.question.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
 | 
			
		||||
        if (typeof this.question.text == 'undefined') {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return element;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component of type essay.
 | 
			
		||||
     *
 | 
			
		||||
     * @param review Whether we're in review mode.
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initEssayComponent(review?: boolean): void | HTMLElement {
 | 
			
		||||
        const questionEl = this.initComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const question = <AddonModQuizEssayQuestion> this.question!;
 | 
			
		||||
        const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
 | 
			
		||||
 | 
			
		||||
        if (question.parsedSettings) {
 | 
			
		||||
            question.allowsAttachments = question.parsedSettings.attachments != '0';
 | 
			
		||||
            question.allowsAnswerFiles = question.parsedSettings.responseformat == 'editorfilepicker';
 | 
			
		||||
            question.isMonospaced = question.parsedSettings.responseformat == 'monospaced';
 | 
			
		||||
            question.isPlainText = question.isMonospaced || question.parsedSettings.responseformat == 'plain';
 | 
			
		||||
            question.hasInlineText = question.parsedSettings.responseformat != 'noinline';
 | 
			
		||||
        } else {
 | 
			
		||||
            question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
 | 
			
		||||
            question.allowsAnswerFiles = !!answerDraftIdInput;
 | 
			
		||||
            question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
 | 
			
		||||
            question.isPlainText = question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (review) {
 | 
			
		||||
            // Search the answer and the attachments.
 | 
			
		||||
            question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response');
 | 
			
		||||
 | 
			
		||||
            if (question.parsedSettings) {
 | 
			
		||||
                question.attachments = Array.from(
 | 
			
		||||
                    CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'),
 | 
			
		||||
                );
 | 
			
		||||
            } else {
 | 
			
		||||
                question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml(
 | 
			
		||||
                    CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '',
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return questionEl;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
 | 
			
		||||
        question.hasDraftFiles = question.allowsAnswerFiles && CoreQuestionHelper.instance.hasDraftFileUrls(questionEl.innerHTML);
 | 
			
		||||
 | 
			
		||||
        if (!textarea && (question.hasInlineText || !question.allowsAttachments)) {
 | 
			
		||||
            // Textarea not found, we might be in review. Search the answer and the attachments.
 | 
			
		||||
            question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response');
 | 
			
		||||
            question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml(
 | 
			
		||||
                CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '',
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            return questionEl;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (textarea) {
 | 
			
		||||
            const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]');
 | 
			
		||||
            let content = CoreTextUtils.instance.decodeHTML(textarea.innerHTML || '');
 | 
			
		||||
 | 
			
		||||
            if (question.hasDraftFiles && question.responsefileareas) {
 | 
			
		||||
                content = CoreTextUtils.instance.replaceDraftfileUrls(
 | 
			
		||||
                    CoreSites.instance.getCurrentSite()!.getURL(),
 | 
			
		||||
                    content,
 | 
			
		||||
                    CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'),
 | 
			
		||||
                ).text;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            question.textarea = {
 | 
			
		||||
                id: textarea.id,
 | 
			
		||||
                name: textarea.name,
 | 
			
		||||
                text: content,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            if (input) {
 | 
			
		||||
                question.formatInput = {
 | 
			
		||||
                    name: input.name,
 | 
			
		||||
                    value: input.value,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (answerDraftIdInput) {
 | 
			
		||||
            question.answerDraftIdInput = {
 | 
			
		||||
                name: answerDraftIdInput.name,
 | 
			
		||||
                value: Number(answerDraftIdInput.value),
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (question.allowsAttachments) {
 | 
			
		||||
            const attachmentsInput = <HTMLInputElement> questionEl.querySelector('.attachments input[name*=_attachments]');
 | 
			
		||||
            const objectElement = <HTMLObjectElement> questionEl.querySelector('.attachments object');
 | 
			
		||||
            const fileManagerUrl = objectElement && objectElement.data;
 | 
			
		||||
 | 
			
		||||
            if (attachmentsInput) {
 | 
			
		||||
                question.attachmentsDraftIdInput = {
 | 
			
		||||
                    name: attachmentsInput.name,
 | 
			
		||||
                    value: Number(attachmentsInput.value),
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (question.parsedSettings) {
 | 
			
		||||
                question.attachmentsMaxFiles = Number(question.parsedSettings.attachments);
 | 
			
		||||
                question.attachmentsAcceptedTypes = (<string[] | undefined> question.parsedSettings.filetypeslist)?.join(',');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (fileManagerUrl) {
 | 
			
		||||
                const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
 | 
			
		||||
                const maxBytes = Number(params.maxbytes);
 | 
			
		||||
                const areaMaxBytes = Number(params.areamaxbytes);
 | 
			
		||||
 | 
			
		||||
                question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
 | 
			
		||||
                    Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return questionEl;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component that uses the original question text with some basic treatment.
 | 
			
		||||
     *
 | 
			
		||||
     * @param contentSelector The selector to find the question content (text).
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initOriginalTextComponent(contentSelector: string): void | HTMLElement {
 | 
			
		||||
        if (!this.question) {
 | 
			
		||||
            this.logger.warn('Aborting because of no question received.');
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const element = CoreDomUtils.instance.convertToElement(this.question.html);
 | 
			
		||||
 | 
			
		||||
        // Get question content.
 | 
			
		||||
        const content = <HTMLElement> element.querySelector(contentSelector);
 | 
			
		||||
        if (!content) {
 | 
			
		||||
            this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove sequencecheck and validation error.
 | 
			
		||||
        CoreDomUtils.instance.removeElement(content, 'input[name*=sequencecheck]');
 | 
			
		||||
        CoreDomUtils.instance.removeElement(content, '.validationerror');
 | 
			
		||||
 | 
			
		||||
        // Replace Moodle's correct/incorrect and feedback classes with our own.
 | 
			
		||||
        CoreQuestionHelper.instance.replaceCorrectnessClasses(element);
 | 
			
		||||
        CoreQuestionHelper.instance.replaceFeedbackClasses(element);
 | 
			
		||||
 | 
			
		||||
        // Treat the correct/incorrect icons.
 | 
			
		||||
        CoreQuestionHelper.instance.treatCorrectnessIcons(element);
 | 
			
		||||
 | 
			
		||||
        // Set the question text.
 | 
			
		||||
        this.question.text = content.innerHTML;
 | 
			
		||||
 | 
			
		||||
        return element;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component that has an input of type "text".
 | 
			
		||||
     *
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initInputTextComponent(): void | HTMLElement {
 | 
			
		||||
        const questionEl = this.initComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the input element.
 | 
			
		||||
        const question = <AddonModQuizTextQuestion> this.question!;
 | 
			
		||||
        const input = <HTMLInputElement> questionEl.querySelector('input[type="text"][name*=answer]');
 | 
			
		||||
        if (!input) {
 | 
			
		||||
            this.logger.warn('Aborting because couldn\'t find input.', this.question!.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        question.input = {
 | 
			
		||||
            id: input.id,
 | 
			
		||||
            name: input.name,
 | 
			
		||||
            value: input.value,
 | 
			
		||||
            readOnly: input.readOnly,
 | 
			
		||||
            isInline: !!CoreDomUtils.instance.closest(input, '.qtext'), // The answer can be inside the question text.
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Check if question is marked as correct.
 | 
			
		||||
        if (input.classList.contains('incorrect')) {
 | 
			
		||||
            question.input.correctClass = 'core-question-incorrect';
 | 
			
		||||
            question.input.correctIcon = 'fa-remove';
 | 
			
		||||
            question.input.correctIconColor = 'danger';
 | 
			
		||||
        } else if (input.classList.contains('correct')) {
 | 
			
		||||
            question.input.correctClass = 'core-question-correct';
 | 
			
		||||
            question.input.correctIcon = 'fa-check';
 | 
			
		||||
            question.input.correctIconColor = 'success';
 | 
			
		||||
        } else if (input.classList.contains('partiallycorrect')) {
 | 
			
		||||
            question.input.correctClass = 'core-question-partiallycorrect';
 | 
			
		||||
            question.input.correctIcon = 'fa-check-square';
 | 
			
		||||
            question.input.correctIconColor = 'warning';
 | 
			
		||||
        } else {
 | 
			
		||||
            question.input.correctClass = '';
 | 
			
		||||
            question.input.correctIcon = '';
 | 
			
		||||
            question.input.correctIconColor = '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (question.input.isInline) {
 | 
			
		||||
            // Handle correct/incorrect classes and icons.
 | 
			
		||||
            const content = <HTMLElement> questionEl.querySelector('.qtext');
 | 
			
		||||
 | 
			
		||||
            CoreQuestionHelper.instance.replaceCorrectnessClasses(content);
 | 
			
		||||
            CoreQuestionHelper.instance.treatCorrectnessIcons(content);
 | 
			
		||||
 | 
			
		||||
            question.text = content.innerHTML;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return questionEl;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component with a "match" behaviour.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initMatchComponent(): void | HTMLElement {
 | 
			
		||||
        const questionEl = this.initComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Find rows.
 | 
			
		||||
        const question = <AddonModQuizMatchQuestion> this.question!;
 | 
			
		||||
        const rows = Array.from(questionEl.querySelectorAll('table.answer tr'));
 | 
			
		||||
        if (!rows || !rows.length) {
 | 
			
		||||
            this.logger.warn('Aborting because couldn\'t find any row.', question.slot);
 | 
			
		||||
 | 
			
		||||
            return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        question.rows = [];
 | 
			
		||||
 | 
			
		||||
        for (const i in rows) {
 | 
			
		||||
            const row = rows[i];
 | 
			
		||||
            const columns = Array.from(row.querySelectorAll('td'));
 | 
			
		||||
 | 
			
		||||
            if (!columns || columns.length < 2) {
 | 
			
		||||
                this.logger.warn('Aborting because couldn\'t the right columns.', question.slot);
 | 
			
		||||
 | 
			
		||||
                return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get the select and the options.
 | 
			
		||||
            const select = columns[1].querySelector('select');
 | 
			
		||||
            const options = Array.from(columns[1].querySelectorAll('option'));
 | 
			
		||||
 | 
			
		||||
            if (!select || !options || !options.length) {
 | 
			
		||||
                this.logger.warn('Aborting because couldn\'t find select or options.', question.slot);
 | 
			
		||||
 | 
			
		||||
                return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const rowModel: AddonModQuizQuestionMatchSelect = {
 | 
			
		||||
                id: select.id.replace(/:/g, '\\:'),
 | 
			
		||||
                name: select.name,
 | 
			
		||||
                disabled: select.disabled,
 | 
			
		||||
                options: [],
 | 
			
		||||
                text: columns[0].innerHTML, // Row's text should be in the first column.
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            // Check if answer is correct.
 | 
			
		||||
            if (columns[1].className.indexOf('incorrect') >= 0) {
 | 
			
		||||
                rowModel.isCorrect = 0;
 | 
			
		||||
            } else if (columns[1].className.indexOf('correct') >= 0) {
 | 
			
		||||
                rowModel.isCorrect = 1;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Treat each option.
 | 
			
		||||
            for (const j in options) {
 | 
			
		||||
                const optionEl = options[j];
 | 
			
		||||
 | 
			
		||||
                if (typeof optionEl.value == 'undefined') {
 | 
			
		||||
                    this.logger.warn('Aborting because couldn\'t find the value of an option.', question.slot);
 | 
			
		||||
 | 
			
		||||
                    return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const option: AddonModQuizQuestionSelectOption = {
 | 
			
		||||
                    value: optionEl.value,
 | 
			
		||||
                    label: optionEl.innerHTML,
 | 
			
		||||
                    selected: optionEl.selected,
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                if (option.selected) {
 | 
			
		||||
                    rowModel.selected = option.value;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                rowModel.options.push(option);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get the accessibility label.
 | 
			
		||||
            const accessibilityLabel = columns[1].querySelector('label.accesshide');
 | 
			
		||||
            rowModel.accessibilityLabel = accessibilityLabel?.innerHTML;
 | 
			
		||||
 | 
			
		||||
            question.rows.push(rowModel);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        question.loaded = true;
 | 
			
		||||
 | 
			
		||||
        return questionEl;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize a question component with a multiple choice (checkbox) or single choice (radio).
 | 
			
		||||
     *
 | 
			
		||||
     * @return Element containing the question HTML, void if the data is not valid.
 | 
			
		||||
     */
 | 
			
		||||
    initMultichoiceComponent(): void | HTMLElement {
 | 
			
		||||
        const questionEl = this.initComponent();
 | 
			
		||||
        if (!questionEl) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the prompt.
 | 
			
		||||
        const question = <AddonModQuizMultichoiceQuestion> this.question!;
 | 
			
		||||
        question.prompt = CoreDomUtils.instance.getContentsOfElement(questionEl, '.prompt');
 | 
			
		||||
 | 
			
		||||
        // Search radio buttons first (single choice).
 | 
			
		||||
        let options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
 | 
			
		||||
        if (!options || !options.length) {
 | 
			
		||||
            // Radio buttons not found, it should be a multi answer. Search for checkbox.
 | 
			
		||||
            question.multi = true;
 | 
			
		||||
            options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="checkbox"]'));
 | 
			
		||||
 | 
			
		||||
            if (!options || !options.length) {
 | 
			
		||||
                // No checkbox found either. Abort.
 | 
			
		||||
                this.logger.warn('Aborting because of no radio and checkbox found.', question.slot);
 | 
			
		||||
 | 
			
		||||
                return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        question.options = [];
 | 
			
		||||
        question.disabled = true;
 | 
			
		||||
 | 
			
		||||
        for (const i in options) {
 | 
			
		||||
            const element = options[i];
 | 
			
		||||
            const option: AddonModQuizQuestionRadioOption = {
 | 
			
		||||
                id: element.id,
 | 
			
		||||
                name: element.name,
 | 
			
		||||
                value: element.value,
 | 
			
		||||
                checked: element.checked,
 | 
			
		||||
                disabled: element.disabled,
 | 
			
		||||
            };
 | 
			
		||||
            const parent = element.parentElement;
 | 
			
		||||
 | 
			
		||||
            if (option.value == '-1') {
 | 
			
		||||
                // It's the clear choice option, ignore it.
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            question.optionsName = option.name;
 | 
			
		||||
            question.disabled = question.disabled && element.disabled;
 | 
			
		||||
 | 
			
		||||
            // Get the label with the question text. Try the new format first.
 | 
			
		||||
            const labelId = element.getAttribute('aria-labelledby');
 | 
			
		||||
            let label = labelId ? questionEl.querySelector('#' + labelId.replace(/:/g, '\\:')) : undefined;
 | 
			
		||||
            if (!label) {
 | 
			
		||||
                // Not found, use the old format.
 | 
			
		||||
                label = questionEl.querySelector('label[for="' + option.id + '"]');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Check that we were able to successfully extract options required data.
 | 
			
		||||
            if (!label || option.name === undefined || option.value === undefined) {
 | 
			
		||||
                // Something went wrong when extracting the questions data. Abort.
 | 
			
		||||
                this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
 | 
			
		||||
 | 
			
		||||
                return CoreQuestionHelper.instance.showComponentError(this.onAbort);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            option.text = label.innerHTML;
 | 
			
		||||
 | 
			
		||||
            if (element.checked) {
 | 
			
		||||
                // If the option is checked and it's a single choice we use the model to select the one.
 | 
			
		||||
                if (!question.multi) {
 | 
			
		||||
                    question.singleChoiceModel = option.value;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (parent) {
 | 
			
		||||
                    // Check if answer is correct.
 | 
			
		||||
                    if (parent && parent.className.indexOf('incorrect') >= 0) {
 | 
			
		||||
                        option.isCorrect = 0;
 | 
			
		||||
                    } else if (parent && parent.className.indexOf('correct') >= 0) {
 | 
			
		||||
                        option.isCorrect = 1;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    // Search the feedback.
 | 
			
		||||
                    const feedback = parent.querySelector('.specificfeedback');
 | 
			
		||||
                    if (feedback) {
 | 
			
		||||
                        option.feedback = feedback.innerHTML;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            question.options.push(option);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return questionEl;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Any possible types of question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestion = AddonModQuizCalculatedQuestion | AddonModQuizEssayQuestion | AddonModQuizTextQuestion |
 | 
			
		||||
AddonModQuizMatchQuestion | AddonModQuizMultichoiceQuestion;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Basic data for question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionBasicData = CoreQuestionQuestion & {
 | 
			
		||||
    text?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for calculated question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizCalculatedQuestion = AddonModQuizTextQuestion & {
 | 
			
		||||
    select?: AddonModQuizQuestionSelect; // Select data if units use a select.
 | 
			
		||||
    selectFirst?: boolean; // Whether the select is first or after the input.
 | 
			
		||||
    options?: AddonModQuizQuestionRadioOption[]; // Options if units use radio buttons.
 | 
			
		||||
    optionsName?: string; // Options name (for radio buttons).
 | 
			
		||||
    unit?: string; // Option selected (for radio buttons).
 | 
			
		||||
    optionsFirst?: boolean; // Whether the radio buttons are first or after the input.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for a select.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionSelect = {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    disabled: boolean;
 | 
			
		||||
    options: AddonModQuizQuestionSelectOption[];
 | 
			
		||||
    selected?: string;
 | 
			
		||||
    accessibilityLabel?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for each option in a select.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionSelectOption = {
 | 
			
		||||
    value: string;
 | 
			
		||||
    label: string;
 | 
			
		||||
    selected: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for radio button.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionRadioOption = {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    value: string;
 | 
			
		||||
    disabled: boolean;
 | 
			
		||||
    checked: boolean;
 | 
			
		||||
    text?: string;
 | 
			
		||||
    isCorrect?: number;
 | 
			
		||||
    feedback?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for essay question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizEssayQuestion = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    allowsAttachments?: boolean; // Whether the question allows attachments.
 | 
			
		||||
    allowsAnswerFiles?: boolean; // Whether the question allows adding files in the answer.
 | 
			
		||||
    isMonospaced?: boolean; // Whether the answer is monospaced.
 | 
			
		||||
    isPlainText?: boolean; // Whether the answer is plain text.
 | 
			
		||||
    hasInlineText?: boolean; // // Whether the answer has inline text
 | 
			
		||||
    answer?: string; // Question answer text.
 | 
			
		||||
    attachments?: CoreWSExternalFile[]; // Question answer attachments.
 | 
			
		||||
    hasDraftFiles?: boolean; // Whether the question has draft files.
 | 
			
		||||
    textarea?: AddonModQuizQuestionTextarea; // Textarea data.
 | 
			
		||||
    formatInput?: { name: string; value: string }; // Format input data.
 | 
			
		||||
    answerDraftIdInput?: { name: string; value: number }; // Answer draft id input data.
 | 
			
		||||
    attachmentsDraftIdInput?: { name: string; value: number }; // Attachments draft id input data.
 | 
			
		||||
    attachmentsMaxFiles?: number; // Max number of attachments.
 | 
			
		||||
    attachmentsAcceptedTypes?: string; // Attachments accepted file types.
 | 
			
		||||
    attachmentsMaxBytes?: number; // Max bytes for attachments.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for textarea.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionTextarea = {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    text: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for text question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizTextQuestion = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    input?: AddonModQuizQuestionTextInput;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for text input.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionTextInput = {
 | 
			
		||||
    id: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    value: string;
 | 
			
		||||
    readOnly: boolean;
 | 
			
		||||
    isInline: boolean;
 | 
			
		||||
    correctClass?: string;
 | 
			
		||||
    correctIcon?: string;
 | 
			
		||||
    correctIconColor?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for match question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizMatchQuestion = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    loaded?: boolean; // Whether the question is loaded.
 | 
			
		||||
    rows?: AddonModQuizQuestionMatchSelect[]; // Data for each row.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Each select data for match questions.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionMatchSelect = AddonModQuizQuestionSelect & {
 | 
			
		||||
    text: string;
 | 
			
		||||
    isCorrect?: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data for multichoice question.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizMultichoiceQuestion = AddonModQuizQuestionBasicData & {
 | 
			
		||||
    prompt?: string; // Question prompt.
 | 
			
		||||
    multi?: boolean; // Whether the question allows more than one selected answer.
 | 
			
		||||
    options?: AddonModQuizQuestionRadioOption[]; // List of options.
 | 
			
		||||
    disabled?: boolean; // Whether the question is disabled.
 | 
			
		||||
    optionsName?: string; // Name to use for the options in single choice.
 | 
			
		||||
    singleChoiceModel?: string; // Model for single choice.
 | 
			
		||||
};
 | 
			
		||||
@ -1095,7 +1095,6 @@ export class CoreFileProvider {
 | 
			
		||||
            const entries = await this.getDirectoryContents(dirPath);
 | 
			
		||||
 | 
			
		||||
            const 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<string, unknown>, name: string): string {
 | 
			
		||||
        if (typeof usedNames[name.toLowerCase()] == 'undefined') {
 | 
			
		||||
            // No file with the same name.
 | 
			
		||||
            return name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Repeated name. Add a number until we find a free name.
 | 
			
		||||
        const nameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(name);
 | 
			
		||||
        let extension = CoreMimetypeUtils.instance.getFileExtension(name);
 | 
			
		||||
        let num = 1;
 | 
			
		||||
        extension = extension ? '.' + extension : '';
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            name = nameWithoutExtension + '(' + num + ')' + extension;
 | 
			
		||||
            num++;
 | 
			
		||||
        } while (typeof usedNames[name.toLowerCase()] != 'undefined');
 | 
			
		||||
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove app temporary folder.
 | 
			
		||||
     *
 | 
			
		||||
 | 
			
		||||
@ -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 = <HTMLElement> (selector ? container.querySelector(selector) : container);
 | 
			
		||||
        let positionTop = 0;
 | 
			
		||||
 | 
			
		||||
@ -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.
 | 
			
		||||
     *
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user