From 6aa44eb943d35ed9cdc8c23eaa357fce5476bab9 Mon Sep 17 00:00:00 2001 From: Afzal Khan Date: Mon, 3 Jun 2024 08:02:40 -0400 Subject: [PATCH] Fix part of #20303: Add oppia-image-uploader-modal component (#20375) * Fix part of #20303: Add oppia-image-uploader-modal component * refactor code * add function for invalid tags or attributes * refactor code * make svgString global variable * make previewTitle and description optional * remove unnecessary ngIf * place i18n keys in sorted order --- assets/i18n/en.json | 5 + assets/i18n/qqq.json | 5 + .../image-uploader-modal.component.html | 224 ++++++++++++++++ .../image-uploader-modal.component.spec.ts | 251 ++++++++++++++++++ .../image-uploader-modal.component.ts | 234 ++++++++++++++++ .../components/shared-component.module.ts | 4 + 6 files changed, 723 insertions(+) create mode 100644 core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.html create mode 100644 core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.spec.ts create mode 100644 core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.ts diff --git a/assets/i18n/en.json b/assets/i18n/en.json index c82b3ad1b9a7..80128d174bbb 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -660,6 +660,11 @@ "I18N_HEADING_VOLUNTEER": "Volunteer", "I18N_HINT_NEED_HELP": "Need help? View a hint for this problem!", "I18N_HINT_TITLE": "Hint", + "I18N_IMAGE_UPLOADER_MODAL_ADD_BUTTON": "Add <[imageName]>", + "I18N_IMAGE_UPLOADER_MODAL_CANCEL_BUTTON": "Cancel", + "I18N_IMAGE_UPLOADER_MODAL_DRAG": "Drag to crop and resize:", + "I18N_IMAGE_UPLOADER_MODAL_UPLOAD_HEADING": "Upload <[imageName]>", + "I18N_IMAGE_UPLOAD_ERROR": "Error: Could not read image file.", "I18N_INTERACTIONS_ALGEBRAIC_EXPR_INSTRUCTION": "Type an expression here.", "I18N_INTERACTIONS_CODE_REPL_INSTRUCTION": "Type code in the editor", "I18N_INTERACTIONS_CODE_REPL_NARROW_INSTRUCTION": "Go to code editor", diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json index 59c446a063b9..a8e88844a316 100644 --- a/assets/i18n/qqq.json +++ b/assets/i18n/qqq.json @@ -660,6 +660,11 @@ "I18N_HEADING_VOLUNTEER": "Text shown in the footer and navbar. - Link to a page that contains Oppia's Volunteer's page", "I18N_HINT_NEED_HELP": "Text shown in the modal to prompt the learner to view a hint.", "I18N_HINT_TITLE": "Title of the modal that shows a hint to the learner.", + "I18N_IMAGE_UPLOADER_MODAL_ADD_BUTTON": "Text displayed in the image uploader modal. - Text of the add button of the dialog shown to upload an image.\n{{Identical|Add}}", + "I18N_IMAGE_UPLOADER_MODAL_CANCEL_BUTTON": "Text displayed in the image uploader modal. - Text of the cancel button of the dialog shown to upload an image.\n{{Identical|Cancel}}", + "I18N_IMAGE_UPLOADER_MODAL_DRAG": "Text displayed in the image uploader modal instructing the user to drag and drop the image to crop it.", + "I18N_IMAGE_UPLOADER_MODAL_UPLOAD_HEADING": "Heading displayed at the top of the image uploader modal.", + "I18N_IMAGE_UPLOAD_ERROR": "Text displayed in the image uploader modal. - Error text of the dialog shown to upload a image. This error is shown when the file uploaded by the user is not an image.", "I18N_INTERACTIONS_ALGEBRAIC_EXPR_INSTRUCTION": "Instructions to the learner to interact with the AlgebraicExpression interaction.", "I18N_INTERACTIONS_CODE_REPL_INSTRUCTION": "Instructions to the learner to interact with the CodeRepl interaction.", "I18N_INTERACTIONS_CODE_REPL_NARROW_INSTRUCTION": "Shorter instructions to the learner to interact with the CodeRepl interaction.", diff --git a/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.html b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.html new file mode 100644 index 000000000000..7b665d69c3ef --- /dev/null +++ b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.html @@ -0,0 +1,224 @@ +
+ + + + + +
+ diff --git a/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.spec.ts b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.spec.ts new file mode 100644 index 000000000000..0712324717c6 --- /dev/null +++ b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.spec.ts @@ -0,0 +1,251 @@ +// Copyright 2024 The Oppia Authors. All Rights Reserved. +// +// 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. + +/** + * @fileoverview Unit tests for image uploader modal. + */ + +import {ChangeDetectorRef, ElementRef, NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import Cropper from 'cropperjs'; +import {SvgSanitizerService} from 'services/svg-sanitizer.service'; +import {MockTranslatePipe} from 'tests/unit-test-utils'; +import {ImageUploaderModalComponent} from './image-uploader-modal.component'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +import {of} from 'rxjs'; +import {HttpClientTestingModule} from '@angular/common/http/testing'; + +describe('Image Uploader Modal', () => { + let fixture: ComponentFixture; + let componentInstance: ImageUploaderModalComponent; + let windowDimensionsService: WindowDimensionsService; + let resizeEvent = new Event('resize'); + const svgString = + ''; + + class MockChangeDetectorRef { + detectChanges(): void {} + } + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ImageUploaderModalComponent, MockTranslatePipe], + imports: [HttpClientTestingModule], + providers: [ + NgbActiveModal, + SvgSanitizerService, + { + provide: ChangeDetectorRef, + useClass: MockChangeDetectorRef, + }, + { + provide: WindowDimensionsService, + useValue: { + isWindowNarrow: () => true, + getResizeEvent: () => of(resizeEvent), + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ImageUploaderModalComponent); + windowDimensionsService = TestBed.inject(WindowDimensionsService); + componentInstance = fixture.componentInstance; + componentInstance.aspectRatio = '4:3'; + }); + + it('should create', () => { + expect(componentInstance).toBeDefined(); + }); + + it('should initialize cropper when window is not narrow', () => { + spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(false); + fixture.detectChanges(); + componentInstance.croppableImageRef = new ElementRef( + document.createElement('img') + ); + + componentInstance.initializeCropper(); + + expect(componentInstance.cropper).toBeDefined(); + }); + + it('should initialize cropper when window is narrow', () => { + spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(true); + fixture.detectChanges(); + componentInstance.croppableImageRef = new ElementRef( + document.createElement('img') + ); + + componentInstance.initializeCropper(); + + expect(componentInstance.cropper).toBeDefined(); + }); + + it('should reset', () => { + componentInstance.reset(); + expect(componentInstance.uploadedImage).toBeNull(); + expect(componentInstance.invalidTagsAndAttributes).toEqual({ + tags: [], + attrs: [], + }); + }); + + it('should handle image upload and confirm image', () => { + const dataBase64Mock = 'VEhJUyBJUyBUSEUgQU5TV0VSCg=='; + const arrayBuffer = Uint8Array.from(window.atob(dataBase64Mock), c => + c.charCodeAt(0) + ); + const file = new File([arrayBuffer], 'filename.png'); + + const fileReaderMock = { + readAsDataURL: jasmine + .createSpy('readAsDataURL') + .and.callFake(function () { + const event = { + target: {result: 'base64ImageData'}, + } as ProgressEvent; + if (this.onload) { + this.onload(event); + } + }), + addEventListener: jasmine.createSpy('addEventListener'), + removeEventListener: jasmine.createSpy('removeEventListener'), + onload: null as + | ((this: FileReader, ev: ProgressEvent) => void) + | null, + }; + + spyOn(window, 'FileReader').and.returnValue( + fileReaderMock as unknown as FileReader + ); + + componentInstance.onFileChanged(file); + expect(componentInstance.invalidImageWarningIsShown).toBeFalse(); + + const imageDataUrl = 'base64ImageData'; + componentInstance.cropper = { + getCroppedCanvas: () => { + return { + toDataURL: () => imageDataUrl, + }; + }, + } as Cropper; + + componentInstance.confirm(); + expect(componentInstance.croppedImageDataUrl).toEqual(imageDataUrl); + }); + + it( + 'should confirm and set croppedImageDataUrl same as uploadedImage ' + + 'in case of Thumbnail', + () => { + componentInstance.imageName = 'Thumbnail'; + componentInstance.ngOnInit(); + let file = new File([svgString], 'test.svg', {type: 'image/svg+xml'}); + componentInstance.invalidImageWarningIsShown = false; + + componentInstance.onFileChanged(file); + + expect(componentInstance.invalidImageWarningIsShown).toBeFalse(); + + const confirmSpy = spyOn(componentInstance, 'confirm').and.callThrough(); + + componentInstance.confirm(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(componentInstance.croppedImageDataUrl).toEqual( + componentInstance.uploadedImage as string + ); + } + ); + + it('should remove invalid tags and attributes', () => { + componentInstance.ngOnInit(); + let file = new File([svgString], 'test.svg', {type: 'image/svg+xml'}); + componentInstance.invalidImageWarningIsShown = false; + + componentInstance.onFileChanged(file); + expect(componentInstance.areInvalidTagsOrAttrsPresent()).toBeFalse(); + expect(componentInstance.invalidImageWarningIsShown).toBeFalse(); + }); + + it( + 'should update background color if the new color is different' + + ' from the current color', + () => { + componentInstance.bgColor = 'red'; + componentInstance.updateBackgroundColor('blue'); + expect(componentInstance.bgColor).toBe('blue'); + } + ); + + it( + 'should not update background color if the new color is the same' + + 'as the current color', + () => { + componentInstance.bgColor = 'red'; + componentInstance.updateBackgroundColor('red'); + expect(componentInstance.bgColor).toBe('red'); + } + ); + + it('should set uploadedImage if previewImageUrl is provided', () => { + componentInstance.imageName = 'Thumbnail'; + componentInstance.previewImageUrl = 'test_url'; + componentInstance.ngOnInit(); + + expect(componentInstance.uploadedImage).toBe('test_url'); + }); + + it('should handle invalid image', () => { + spyOn(componentInstance, 'reset'); + componentInstance.onInvalidImageLoaded(); + expect(componentInstance.reset).toHaveBeenCalled(); + expect(componentInstance.invalidImageWarningIsShown).toBeTrue(); + }); + + it('should throw error if cropper is not initialized', () => { + expect(() => { + componentInstance.confirm(); + }).toThrowError('Cropper has not been initialized'); + }); + + it('should handle file read errors', () => { + spyOn(componentInstance, 'onInvalidImageLoaded'); + const file = new File([], 'invalid.png'); + const reader = new FileReader(); + + spyOn(reader, 'readAsDataURL').and.callFake(function () { + const errorEvent = new ProgressEvent('error'); + this.onerror(errorEvent); + }); + + spyOn(window, 'FileReader').and.returnValue(reader); + + componentInstance.onFileChanged(file); + expect(componentInstance.onInvalidImageLoaded).toHaveBeenCalled(); + }); +}); diff --git a/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.ts b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.ts new file mode 100644 index 000000000000..3b69d418a4d3 --- /dev/null +++ b/core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.ts @@ -0,0 +1,234 @@ +// Copyright 2024 The Oppia Authors. All Rights Reserved. +// +// 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. + +/** + * @fileoverview Component for image uploader modal. + */ + +import { + ChangeDetectorRef, + Component, + ElementRef, + Input, + ViewChild, +} from '@angular/core'; +import {SafeResourceUrl} from '@angular/platform-browser'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component'; +import Cropper from 'cropperjs'; +import {SvgSanitizerService} from 'services/svg-sanitizer.service'; +import {WindowDimensionsService} from 'services/contextual/window-dimensions.service'; +require('cropperjs/dist/cropper.min.css'); + +@Component({ + selector: 'oppia-image-uploader-modal', + templateUrl: './image-uploader-modal.component.html', +}) +export class ImageUploaderModalComponent extends ConfirmOrCancelModal { + // These properties are initialized using Angular lifecycle hooks + // and we need to do non-null assertion. For more information, see + // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1 + @Input() allowedImageFormats!: string[]; + @Input() bgColor!: string; + @Input() previewFooter!: string; + @Input() previewDescription!: string; + @Input() previewTitle!: string; + @Input() previewDescriptionBgColor!: string; + @Input() imageName!: string; + @Input() aspectRatio!: string; + @Input() maxImageSizeInKB!: number; + @Input() previewImageUrl!: string; + @Input() allowedBgColors!: string[]; + + // 'uploadedImage' will be null if the uploaded svg is invalid or not trusted. + uploadedImage: SafeResourceUrl | string | null = null; + invalidImageWarningIsShown: boolean = false; + windowIsNarrow: boolean = false; + invalidTagsAndAttributes: {tags: string[]; attrs: string[]} = { + tags: [], + attrs: [], + }; + dimensions = {height: 0, width: 0}; + imageType!: string; + croppedImageDataUrl!: string; + + // 'cropper' is initialized before it is to be used, hence we need to do + // non-null assertion, for more information see + // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1 + cropper!: Cropper; + @ViewChild('croppableImage') croppableImageRef!: ElementRef; + + constructor( + private changeDetectorRef: ChangeDetectorRef, + private ngbActiveModal: NgbActiveModal, + private windowDimensionService: WindowDimensionsService, + private svgSanitizerService: SvgSanitizerService + ) { + super(ngbActiveModal); + } + + private _getAspectRatio(): number { + return ( + parseInt(this.aspectRatio.split(':')[0]) / + parseInt(this.aspectRatio.split(':')[1]) + ); + } + + isThumbnail(): boolean { + return this.imageName === 'Thumbnail'; + } + + imageNotUploaded(): boolean { + return !this.uploadedImage; + } + + areInvalidTagsOrAttrsPresent(): boolean { + return ( + this.invalidTagsAndAttributes.tags.length > 0 || + this.invalidTagsAndAttributes.attrs.length > 0 + ); + } + + hasMoreThanOneBgColor(): boolean { + return this.allowedBgColors.length > 1; + } + + initializeCropper(): void { + if (!this.croppableImageRef) { + return; + } + let imageElement = this.croppableImageRef.nativeElement; + if (this.windowIsNarrow) { + this.cropper = new Cropper(imageElement, { + minContainerWidth: 200, + minContainerHeight: 200, + aspectRatio: this._getAspectRatio(), + }); + } else { + this.cropper = new Cropper(imageElement, { + minContainerWidth: 500, + minContainerHeight: 350, + aspectRatio: this._getAspectRatio(), + }); + } + } + + onFileChanged(file: Blob): void { + this.invalidImageWarningIsShown = false; + let reader = new FileReader(); + reader.onload = e => { + this.invalidTagsAndAttributes = { + tags: [], + attrs: [], + }; + let imageData = (e.target as FileReader).result as string; + if (this.svgSanitizerService.isBase64Svg(imageData)) { + this.invalidTagsAndAttributes = + this.svgSanitizerService.getInvalidSvgTagsAndAttrsFromDataUri( + imageData + ); + if (this.isThumbnail()) { + this.uploadedImage = + this.svgSanitizerService.removeAllInvalidTagsAndAttributes( + imageData + ); + } else { + this.uploadedImage = + this.svgSanitizerService.getTrustedSvgResourceUrl(imageData); + } + } + if (!this.uploadedImage) { + this.uploadedImage = decodeURIComponent( + (e.target as FileReader).result as string + ); + } + + const img = new Image(); + img.onload = () => { + this.dimensions = {height: img.height, width: img.width}; + + try { + this.changeDetectorRef.detectChanges(); + } catch (viewDestroyedError) { + // This try catch block handles the following error in FE tests: + // ViewDestroyedError: + // Attempt to use a destroyed view: detectChanges thrown. + // No further action is needed. + } + if (!this.isThumbnail()) { + this.initializeCropper(); + } + }; + img.src = imageData; + this.imageType = file.type; + }; + reader.onerror = () => { + this.onInvalidImageLoaded(); + }; + reader.readAsDataURL(file); + } + + reset(): void { + this.invalidTagsAndAttributes = { + tags: [], + attrs: [], + }; + this.uploadedImage = null; + } + + onInvalidImageLoaded(): void { + this.reset(); + this.invalidImageWarningIsShown = true; + } + + updateBackgroundColor(color: string): void { + if (color !== this.bgColor) { + this.bgColor = color; + } + } + + confirm(): void { + if (this.isThumbnail()) { + this.croppedImageDataUrl = this.uploadedImage as string; + } else { + if (!this.cropper) { + throw new Error('Cropper has not been initialized'); + } + this.croppedImageDataUrl = this.cropper + .getCroppedCanvas({ + height: this.dimensions.height, + width: this.dimensions.width, + }) + .toDataURL(this.imageType); + } + + super.confirm({ + newImageDataUrl: this.croppedImageDataUrl, + dimensions: this.dimensions, + newBgColor: this.bgColor, + }); + } + + ngOnInit(): void { + if (this.previewImageUrl) { + this.uploadedImage = this.previewImageUrl; + } + + this.windowIsNarrow = this.windowDimensionService.isWindowNarrow(); + + this.windowDimensionService.getResizeEvent().subscribe(() => { + this.windowIsNarrow = this.windowDimensionService.isWindowNarrow(); + }); + } +} diff --git a/core/templates/components/shared-component.module.ts b/core/templates/components/shared-component.module.ts index 2baf0ee90491..0884ea3bc19f 100644 --- a/core/templates/components/shared-component.module.ts +++ b/core/templates/components/shared-component.module.ts @@ -87,6 +87,7 @@ import {ProgressNavComponent} from 'pages/exploration-player-page/layout-directi import {QuestionDifficultySelectorComponent} from './question-difficulty-selector/question-difficulty-selector.component'; import {PreviewThumbnailComponent} from 'pages/topic-editor-page/modal-templates/preview-thumbnail.component'; import {InputResponsePairComponent} from 'pages/exploration-player-page/learner-experience/input-response-pair.component'; +import {ImageUploaderModalComponent} from './forms/custom-forms-directives/image-uploader-modal.component'; import {StorySummaryTileComponent} from './summary-tile/story-summary-tile.component'; import {ExplorationFooterComponent} from 'pages/exploration-player-page/layout-directives/exploration-footer.component'; import {DisplaySolutionModalComponent} from 'pages/exploration-player-page/modals/display-solution-modal.component'; @@ -266,6 +267,7 @@ import {DirectivesModule} from 'directives/directives.module'; HintAndSolutionButtonsComponent, HintEditorComponent, InputResponsePairComponent, + ImageUploaderModalComponent, KeyboardShortcutHelpModalComponent, LearnerAnswerInfoCard, LazyLoadingComponent, @@ -415,6 +417,7 @@ import {DirectivesModule} from 'directives/directives.module'; HintAndSolutionButtonsComponent, HintEditorComponent, InputResponsePairComponent, + ImageUploaderModalComponent, KeyboardShortcutHelpModalComponent, ProgressNavComponent, PreviewThumbnailComponent, @@ -545,6 +548,7 @@ import {DirectivesModule} from 'directives/directives.module'; HintAndSolutionButtonsComponent, HintEditorComponent, InputResponsePairComponent, + ImageUploaderModalComponent, LazyLoadingComponent, ProfileLinkImageComponent, ProfileLinkTextComponent,