From a87ebba4abc0f61677ce44070d7fbfe73bc0d1e4 Mon Sep 17 00:00:00 2001 From: joan Date: Mon, 9 Aug 2021 07:47:50 +0200 Subject: [PATCH 1/2] 2848 - Added photo cropper --- front/package-lock.json | 11 + front/package.json | 1 + .../components/upload-photo/croppie.scss | 252 ++++++++++++++++++ .../salix/components/upload-photo/index.html | 84 ++++-- front/salix/components/upload-photo/index.js | 101 ++++++- .../components/upload-photo/locale/es.yml | 7 +- .../salix/components/upload-photo/style.scss | 26 +- 7 files changed, 426 insertions(+), 56 deletions(-) create mode 100644 front/salix/components/upload-photo/croppie.scss diff --git a/front/package-lock.json b/front/package-lock.json index f49a67194..b62e8179e 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -14,6 +14,7 @@ "angular-animate": "^1.7.8", "angular-translate": "^2.18.1", "angular-translate-loader-partial": "^2.18.1", + "croppie": "^2.6.5", "js-yaml": "^3.13.1", "mg-crud": "^1.1.2", "oclazyload": "^0.6.3", @@ -77,6 +78,11 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/croppie": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz", + "integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ==" + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -200,6 +206,11 @@ "sprintf-js": "~1.0.2" } }, + "croppie": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz", + "integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ==" + }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", diff --git a/front/package.json b/front/package.json index 3ef90bf41..89088cd7a 100644 --- a/front/package.json +++ b/front/package.json @@ -14,6 +14,7 @@ "angular-animate": "^1.7.8", "angular-translate": "^2.18.1", "angular-translate-loader-partial": "^2.18.1", + "croppie": "^2.6.5", "js-yaml": "^3.13.1", "mg-crud": "^1.1.2", "oclazyload": "^0.6.3", diff --git a/front/salix/components/upload-photo/croppie.scss b/front/salix/components/upload-photo/croppie.scss new file mode 100644 index 000000000..1d736f896 --- /dev/null +++ b/front/salix/components/upload-photo/croppie.scss @@ -0,0 +1,252 @@ +@import "./variables"; + + +.croppie-container { + width: 100%; + height: 100%; +} + +.croppie-container .cr-image { + z-index: -1; + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + max-height: none; + max-width: none; +} + +.croppie-container .cr-boundary { + border: 2px solid $color-primary; + position: relative; + overflow: hidden; + margin: 0 auto; + z-index: 1; + width: 100%; + height: 100%; +} + +.croppie-container .cr-viewport, +.croppie-container .cr-resizer { + position: absolute; + border: 2px solid #fff; + margin: auto; + top: 0; + bottom: 0; + right: 0; + left: 0; + box-shadow: 0 0 2000px 2000px rgba(0, 0, 0, 0.5); + z-index: 0; +} + +.croppie-container .cr-resizer { + z-index: 2; + box-shadow: none; + pointer-events: none; +} + +.croppie-container .cr-resizer-vertical, +.croppie-container .cr-resizer-horisontal { + position: absolute; + pointer-events: all; +} + +.croppie-container .cr-resizer-vertical::after, +.croppie-container .cr-resizer-horisontal::after { + display: block; + position: absolute; + box-sizing: border-box; + border: 1px solid black; + background: #fff; + width: 10px; + height: 10px; + content: ''; +} + +.croppie-container .cr-resizer-vertical { + bottom: -5px; + cursor: row-resize; + width: 100%; + height: 10px; +} + +.croppie-container .cr-resizer-vertical::after { + left: 50%; + margin-left: -5px; +} + +.croppie-container .cr-resizer-horisontal { + right: -5px; + cursor: col-resize; + width: 10px; + height: 100%; +} + +.croppie-container .cr-resizer-horisontal::after { + top: 50%; + margin-top: -5px; +} + +.croppie-container .cr-original-image { + display: none; +} + +.croppie-container .cr-vp-circle { + border-radius: 50%; +} + +.croppie-container .cr-overlay { + z-index: 1; + position: absolute; + cursor: move; + touch-action: none; +} + +.croppie-container .cr-slider-wrap { + margin: 15px auto; + text-align: center; +} + +.croppie-result { + position: relative; + overflow: hidden; +} + +.croppie-result img { + position: absolute; +} + +.croppie-container .cr-image, +.croppie-container .cr-overlay, +.croppie-container .cr-viewport { + -webkit-transform: translateZ(0); + -moz-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); +} + +/*************************************/ +/***** STYLING RANGE INPUT ***********/ +/*************************************/ +/*http://brennaobrien.com/blog/2014/05/style-input-type-range-in-every-browser.html */ +/*************************************/ + +.cr-slider { + -webkit-appearance: none; +/*removes default webkit styles*/ + /*border: 1px solid white; *//*fix for FF unable to apply focus style bug */ + width: 300px; +/*required for proper track sizing in FF*/ + max-width: 100%; + padding-top: 8px; + padding-bottom: 8px; + background-color: transparent; +} + +.cr-slider::-webkit-slider-runnable-track { + width: 100%; + height: 3px; + background: rgba(0, 0, 0, 0.2); + border: 0; +} + +.cr-slider::-webkit-slider-thumb { + -webkit-appearance: none; + border: none; + height: 16px; + width: 16px; + border-radius: 50%; + background: $color-primary; + margin-top: -6px; +} + +.cr-slider:focus { + outline: none; +} +/* +.cr-slider:focus::-webkit-slider-runnable-track { +background: #ccc; +} +*/ + +.cr-slider::-moz-range-track { + width: 100%; + height: 3px; + background: rgba(0, 0, 0, 0.5); + border: 0; + border-radius: 3px; +} + +.cr-slider::-moz-range-thumb { + border: none; + height: 16px; + width: 16px; + border-radius: 50%; + background: #ddd; + margin-top: -6px; +} + +/*hide the outline behind the border*/ +.cr-slider:-moz-focusring { + outline: 1px solid white; + outline-offset: -1px; +} + +.cr-slider::-ms-track { + width: 100%; + height: 5px; + background: transparent; +/*remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead */ + border-color: transparent;/*leave room for the larger thumb to overflow with a transparent border */ + border-width: 6px 0; + color: transparent;/*remove default tick marks*/ +} +.cr-slider::-ms-fill-lower { + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; +} +.cr-slider::-ms-fill-upper { + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; +} +.cr-slider::-ms-thumb { + border: none; + height: 16px; + width: 16px; + border-radius: 50%; + background: #ddd; + margin-top:1px; +} +.cr-slider:focus::-ms-fill-lower { + background: rgba(0, 0, 0, 0.5); +} +.cr-slider:focus::-ms-fill-upper { + background: rgba(0, 0, 0, 0.5); +} +/*******************************************/ + +/***********************************/ +/* Rotation Tools */ +/***********************************/ +.cr-rotate-controls { + position: absolute; + bottom: 5px; + left: 5px; + z-index: 1; +} +.cr-rotate-controls button { + border: 0; + background: none; +} +.cr-rotate-controls i:before { + display: inline-block; + font-style: normal; + font-weight: 900; + font-size: 22px; +} +.cr-rotate-l i:before { + content: '↺'; +} +.cr-rotate-r i:before { + content: '↻'; +} diff --git a/front/salix/components/upload-photo/index.html b/front/salix/components/upload-photo/index.html index c36eb7fe1..09b36d531 100644 --- a/front/salix/components/upload-photo/index.html +++ b/front/salix/components/upload-photo/index.html @@ -1,36 +1,62 @@ + message="Edit photo"> - -
-
- - - - - - - - - - - - + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
diff --git a/front/salix/components/upload-photo/index.js b/front/salix/components/upload-photo/index.js index 924a6a8a4..8114b7b4b 100644 --- a/front/salix/components/upload-photo/index.js +++ b/front/salix/components/upload-photo/index.js @@ -1,24 +1,74 @@ import ngModule from '../../module'; import Component from 'core/lib/component'; +import Croppie from 'croppie'; import './style.scss'; +import './croppie.scss'; /** * Small card with basing entity information and actions. */ export default class UploadPhoto extends Component { + constructor($element, $) { + super($element, $); + + this.viewportTypes = [ + { + code: 'normal', + description: this.$t('Normal'), + viewport: { + width: 400, + height: 400 + }, + output: { + width: 1200, + height: 1200 + } + + }, + { + code: 'panoramic', + description: this.$t('Panoramic'), + viewport: { + width: 675, + height: 450 + }, + output: { + width: 1350, + height: 900 + } + } + ]; + this.viewportType = 'normal'; + this.getAllowedContentTypes(); + } + /** * Opens the dialog and sets the default data * @param {*} collection - Collection name * @param {*} id - Entity id */ show(collection, id) { + this.editor = null; this.newPhoto = { id: id, collection: collection, fileName: id }; this.$.dialog.show(); - this.getAllowedContentTypes(); + } + + get viewportSelection() { + return this._viewportSelection; + } + + set viewportSelection(value) { + this._viewportSelection = value; + + if (value && this.newPhoto.files) { + this.displayEditor(); + const files = this.newPhoto.files; + this.updatePhotoPreview(files); + } } getAllowedContentTypes() { @@ -41,12 +91,39 @@ export default class UploadPhoto extends Component { */ updatePhotoPreview(value) { if (value && value[0]) { + if (!this.editor) + this.displayEditor(); + const reader = new FileReader(); - reader.onload = e => this.$.photo.src = e.target.result; + reader.onload = e => this.editor.bind({url: e.target.result}); reader.readAsDataURL(value[0]); } } + displayEditor() { + const viewportType = this.viewportSelection; + const viewport = viewportType.viewport; + const boundaryWidth = viewport.width + 200; + const boundaryHeight = viewport.height + 200; + + const container = document.getElementById('photoContainer'); + if (this.editor) this.editor.destroy(); + this.editor = new Croppie(container, { + viewport: {width: viewport.width, height: viewport.height}, + boundary: {width: boundaryWidth, height: boundaryHeight}, + enableOrientation: true, + showZoomer: true + }); + } + + rotateLeft() { + this.editor.rotate(90); + } + + rotateRight() { + this.editor.rotate(-90); + } + /** * Dialog response handler * @@ -57,7 +134,18 @@ export default class UploadPhoto extends Component { if (!this.newPhoto.files) throw new Error(`Select an image`); - this.makeRequest(); + const viewportType = this.viewportSelection; + const output = viewportType.output; + const options = { + type: 'blob', + size: { + width: output.width, + height: output.height + } + }; + this.editor.result(options) + .then(blob => this.newPhoto.blob = blob) + .then(() => this.makeRequest()); } catch (e) { this.vnApp.showError(this.$t(e.message)); return false; @@ -79,10 +167,11 @@ export default class UploadPhoto extends Component { params: this.newPhoto, headers: {'Content-Type': undefined}, timeout: this.canceler.promise, - transformRequest: files => { + transformRequest: ([file]) => { const formData = new FormData(); - for (let i = 0; i < files.length; i++) - formData.append(files[i].name, files[i]); + const now = new Date(); + const timestamp = now.getTime(); + formData.append('blob', this.newPhoto.blob, file.name + '_' + timestamp); return formData; }, diff --git a/front/salix/components/upload-photo/locale/es.yml b/front/salix/components/upload-photo/locale/es.yml index d2e696ba9..bba3a985a 100644 --- a/front/salix/components/upload-photo/locale/es.yml +++ b/front/salix/components/upload-photo/locale/es.yml @@ -1,3 +1,6 @@ -Upload new photo: Subir una nueva foto +Edit photo: Editar foto Select an image: Selecciona una imagen -File name: Nombre del fichero \ No newline at end of file +File name: Nombre del fichero +Rotate left: Girar a la izquierda +Rotate right: Girar a la derecha +Panoramic: Panorámico \ No newline at end of file diff --git a/front/salix/components/upload-photo/style.scss b/front/salix/components/upload-photo/style.scss index 609364a2c..52e58208c 100644 --- a/front/salix/components/upload-photo/style.scss +++ b/front/salix/components/upload-photo/style.scss @@ -1,26 +1,11 @@ @import "./variables"; .upload-photo { - .photo { - position: relative; - margin: 0 auto; - text-align: center; - & > div { - border: 3px solid $color-primary; - max-width: 256px; - max-height: 256px; - border-radius: 50%; - overflow: hidden - } - - & > div > img[ng-src] { - width: 256px; - height: 256px; - display: block - } + & > vn-horizontal { + align-items: initial; } - + & > vn-spinner { display: block; height: 40px; @@ -28,10 +13,13 @@ } vn-input-file { - max-width: 256px; div.control { overflow: hidden } } + + .form { + align-items: initial; + } } From e3a192bcea5f8d78932f34f7fadb49e19a57cb05 Mon Sep 17 00:00:00 2001 From: joan Date: Mon, 9 Aug 2021 12:37:52 +0200 Subject: [PATCH 2/2] Updated unit test --- front/salix/components/upload-photo/index.js | 7 +- .../components/upload-photo/index.spec.js | 82 +++++++++++++++++-- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/front/salix/components/upload-photo/index.js b/front/salix/components/upload-photo/index.js index 8114b7b4b..4a0180946 100644 --- a/front/salix/components/upload-photo/index.js +++ b/front/salix/components/upload-photo/index.js @@ -143,14 +143,13 @@ export default class UploadPhoto extends Component { height: output.height } }; - this.editor.result(options) + return this.editor.result(options) .then(blob => this.newPhoto.blob = blob) .then(() => this.makeRequest()); } catch (e) { this.vnApp.showError(this.$t(e.message)); return false; } - return true; } /** @@ -171,7 +170,9 @@ export default class UploadPhoto extends Component { const formData = new FormData(); const now = new Date(); const timestamp = now.getTime(); - formData.append('blob', this.newPhoto.blob, file.name + '_' + timestamp); + const fileName = `${file.name}_${timestamp}`; + + formData.append('blob', this.newPhoto.blob, fileName); return formData; }, diff --git a/front/salix/components/upload-photo/index.spec.js b/front/salix/components/upload-photo/index.spec.js index 0ae7a5425..e8ac05fd4 100644 --- a/front/salix/components/upload-photo/index.spec.js +++ b/front/salix/components/upload-photo/index.spec.js @@ -6,7 +6,9 @@ describe('Salix', () => { let $scope; let $httpBackend; - beforeEach(ngModule('salix')); + beforeEach(ngModule('salix', $translateProvider => { + $translateProvider.translations('en', {}); + })); beforeEach(inject(($componentController, $rootScope, _$httpBackend_) => { $scope = $rootScope.$new(); @@ -14,12 +16,58 @@ describe('Salix', () => { const $element = angular.element(''); controller = $componentController('vnUploadPhoto', {$element, $scope}); controller.newPhoto = {}; + controller.$t = m => m; })); afterEach(() => { $scope.$destroy(); }); + describe('viewportSelection()', () => { + it('should call to displayEditor() and updatePhotoPreview() methods', () => { + controller.displayEditor = jest.fn(); + controller.updatePhotoPreview = jest.fn(); + + const files = [{name: 'test.jpg'}]; + controller.newPhoto.files = files; + + controller.viewportSelection = {code: 'normal'}; + + expect(controller.displayEditor).toHaveBeenCalledWith(); + expect(controller.updatePhotoPreview).toHaveBeenCalledWith(files); + }); + }); + + describe('displayEditor()', () => { + it('should define the editor property', () => { + controller.viewportSelection = { + code: 'normal', + description: 'Normal', + viewport: { + width: 400, + height: 400 + }, + output: { + width: 1200, + height: 1200 + } + }; + const element = document.createElement('div'); + + jest.spyOn(document, 'getElementById').mockReturnValue(element); + + controller.displayEditor(); + + const editor = controller.editor; + + expect(editor).toBeDefined(); + expect(editor.options.viewport.width).toEqual(400); + expect(editor.options.viewport.width).toEqual(400); + expect(editor.options.boundary.width).toEqual(600); + expect(editor.options.boundary.height).toEqual(600); + }); + }); + describe('onUploadAccept()', () => { it('should throw an error message containing "Select an image"', () => { jest.spyOn(controller.vnApp, 'showError'); @@ -29,13 +77,33 @@ describe('Salix', () => { expect(controller.vnApp.showError).toHaveBeenCalledWith('Select an image'); }); - it('should call to the makeRequest() method', () => { + it('should call to the makeRequest() method', done => { + controller.editor = { + result: () => {} + }; + jest.spyOn(controller, 'makeRequest'); + jest.spyOn(controller.editor, 'result').mockReturnValue(new Promise(resolve => resolve('blobFile'))); + + controller.viewportSelection = { + code: 'normal', + description: 'Normal', + viewport: { + width: 400, + height: 400 + }, + output: { + width: 1200, + height: 1200 + } + }; controller.newPhoto.files = [0]; - controller.onUploadAccept(); - - expect(controller.makeRequest).toHaveBeenCalledWith(); + controller.onUploadAccept().then(() => { + expect(controller.newPhoto.blob).toEqual('blobFile'); + expect(controller.makeRequest).toHaveBeenCalledWith(); + done(); + }).catch(done.fail); }); }); @@ -44,7 +112,11 @@ describe('Salix', () => { jest.spyOn(controller.vnApp, 'showSuccess'); jest.spyOn(controller, 'emit'); + $httpBackend.expectGET('ImageContainers/allowedContentTypes').respond(200, ['image/jpg']); + controller.newPhoto.files = [{name: 'hola'}]; + controller.newPhoto.blob = new Blob([]); + $httpBackend.expectRoute('POST', 'Images/upload').respond(200); controller.makeRequest(); $httpBackend.flush();