Merge pull request '2848 - Added photo cropper' (#709) from 2848-photo_cropper into dev
gitea/salix/pipeline/head This commit looks good Details

Reviewed-on: #709
Reviewed-by: Carlos Jimenez Ruiz <carlosjr@verdnatura.es>
This commit is contained in:
Carlos Jimenez Ruiz 2021-08-10 13:16:16 +00:00
commit 8be54f6115
8 changed files with 505 additions and 62 deletions

View File

@ -14,6 +14,7 @@
"angular-animate": "^1.7.8", "angular-animate": "^1.7.8",
"angular-translate": "^2.18.1", "angular-translate": "^2.18.1",
"angular-translate-loader-partial": "^2.18.1", "angular-translate-loader-partial": "^2.18.1",
"croppie": "^2.6.5",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"mg-crud": "^1.1.2", "mg-crud": "^1.1.2",
"oclazyload": "^0.6.3", "oclazyload": "^0.6.3",
@ -77,6 +78,11 @@
"sprintf-js": "~1.0.2" "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": { "node_modules/esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@ -200,6 +206,11 @@
"sprintf-js": "~1.0.2" "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": { "esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",

View File

@ -14,6 +14,7 @@
"angular-animate": "^1.7.8", "angular-animate": "^1.7.8",
"angular-translate": "^2.18.1", "angular-translate": "^2.18.1",
"angular-translate-loader-partial": "^2.18.1", "angular-translate-loader-partial": "^2.18.1",
"croppie": "^2.6.5",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"mg-crud": "^1.1.2", "mg-crud": "^1.1.2",
"oclazyload": "^0.6.3", "oclazyload": "^0.6.3",

View File

@ -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: '';
}

View File

@ -1,36 +1,62 @@
<vn-dialog class="edit" <vn-dialog class="edit"
vn-id="dialog" vn-id="dialog"
on-accept="$ctrl.onUploadAccept()" on-accept="$ctrl.onUploadAccept()"
message="Upload new photo"> message="Edit photo">
<tpl-body class="upload-photo"> <tpl-body class="upload-photo">
<vn-horizontal ng-show="file.value" class="photo vn-mb-md"> <vn-horizontal>
<div><img vn-id="photo" ng-src=""/></div> <vn-one ng-if="file.value">
</vn-horizontal> <vn-horizontal>
<vn-horizontal> <vn-icon-button vn-none
<vn-textfield icon="rotate_left"
vn-one vn-tooltip="Rotate left"
label="File name" ng-click="$ctrl.rotateLeft()">
ng-model="$ctrl.newPhoto.fileName" </vn-icon-button>
required="true"> <div id="photoContainer"></div>
</vn-input-file> <vn-icon-button vn-none
</vn-horizontal> icon="rotate_right"
<vn-horizontal> vn-tooltip="Rotate right"
<vn-input-file vn-id="file" ng-click="$ctrl.rotateRight()">
vn-one </vn-icon-button>
label="File" </vn-horizontal>
ng-model="$ctrl.newPhoto.files" </vn-one>
on-change="$ctrl.updatePhotoPreview(value)" <vn-one>
accept="{{$ctrl.allowedContentTypes}}" <vn-horizontal>
required="true"> <vn-input-file vn-id="file"
<append> vn-one
<vn-icon vn-none label="File"
color-marginal ng-model="$ctrl.newPhoto.files"
title="{{$ctrl.contentTypesInfo}}" on-change="$ctrl.updatePhotoPreview(value)"
icon="info"> accept="{{$ctrl.allowedContentTypes}}"
</vn-icon> required="true">
</append> <append>
</vn-input-file> <vn-icon vn-none
</vn-horizontal> color-marginal
title="{{$ctrl.contentTypesInfo}}"
icon="info">
</vn-icon>
</append>
</vn-input-file>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
label="Type"
ng-model="$ctrl.viewportType"
data="$ctrl.viewportTypes"
selection="$ctrl.viewportSelection"
show-field="description"
value-field="code">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
vn-one
label="File name"
ng-model="$ctrl.newPhoto.fileName"
required="true">
</vn-input-file>
</vn-horizontal>
</vn-one>
</vn-horizontal>
</tpl-body> </tpl-body>
<tpl-buttons> <tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/> <input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>

View File

@ -1,24 +1,74 @@
import ngModule from '../../module'; import ngModule from '../../module';
import Component from 'core/lib/component'; import Component from 'core/lib/component';
import Croppie from 'croppie';
import './style.scss'; import './style.scss';
import './croppie.scss';
/** /**
* Small card with basing entity information and actions. * Small card with basing entity information and actions.
*/ */
export default class UploadPhoto extends Component { 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 * Opens the dialog and sets the default data
* @param {*} collection - Collection name * @param {*} collection - Collection name
* @param {*} id - Entity id * @param {*} id - Entity id
*/ */
show(collection, id) { show(collection, id) {
this.editor = null;
this.newPhoto = { this.newPhoto = {
id: id, id: id,
collection: collection, collection: collection,
fileName: id fileName: id
}; };
this.$.dialog.show(); 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() { getAllowedContentTypes() {
@ -41,12 +91,39 @@ export default class UploadPhoto extends Component {
*/ */
updatePhotoPreview(value) { updatePhotoPreview(value) {
if (value && value[0]) { if (value && value[0]) {
if (!this.editor)
this.displayEditor();
const reader = new FileReader(); 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]); 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 * Dialog response handler
* *
@ -57,12 +134,22 @@ export default class UploadPhoto extends Component {
if (!this.newPhoto.files) if (!this.newPhoto.files)
throw new Error(`Select an image`); 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
}
};
return this.editor.result(options)
.then(blob => this.newPhoto.blob = blob)
.then(() => this.makeRequest());
} catch (e) { } catch (e) {
this.vnApp.showError(this.$t(e.message)); this.vnApp.showError(this.$t(e.message));
return false; return false;
} }
return true;
} }
/** /**
@ -79,10 +166,13 @@ export default class UploadPhoto extends Component {
params: this.newPhoto, params: this.newPhoto,
headers: {'Content-Type': undefined}, headers: {'Content-Type': undefined},
timeout: this.canceler.promise, timeout: this.canceler.promise,
transformRequest: files => { transformRequest: ([file]) => {
const formData = new FormData(); const formData = new FormData();
for (let i = 0; i < files.length; i++) const now = new Date();
formData.append(files[i].name, files[i]); const timestamp = now.getTime();
const fileName = `${file.name}_${timestamp}`;
formData.append('blob', this.newPhoto.blob, fileName);
return formData; return formData;
}, },

View File

@ -6,7 +6,9 @@ describe('Salix', () => {
let $scope; let $scope;
let $httpBackend; let $httpBackend;
beforeEach(ngModule('salix')); beforeEach(ngModule('salix', $translateProvider => {
$translateProvider.translations('en', {});
}));
beforeEach(inject(($componentController, $rootScope, _$httpBackend_) => { beforeEach(inject(($componentController, $rootScope, _$httpBackend_) => {
$scope = $rootScope.$new(); $scope = $rootScope.$new();
@ -14,12 +16,58 @@ describe('Salix', () => {
const $element = angular.element('<vn-upload-photo></vn-upload-photo>'); const $element = angular.element('<vn-upload-photo></vn-upload-photo>');
controller = $componentController('vnUploadPhoto', {$element, $scope}); controller = $componentController('vnUploadPhoto', {$element, $scope});
controller.newPhoto = {}; controller.newPhoto = {};
controller.$t = m => m;
})); }));
afterEach(() => { afterEach(() => {
$scope.$destroy(); $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()', () => { describe('onUploadAccept()', () => {
it('should throw an error message containing "Select an image"', () => { it('should throw an error message containing "Select an image"', () => {
jest.spyOn(controller.vnApp, 'showError'); jest.spyOn(controller.vnApp, 'showError');
@ -29,13 +77,33 @@ describe('Salix', () => {
expect(controller.vnApp.showError).toHaveBeenCalledWith('Select an image'); 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, '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.newPhoto.files = [0];
controller.onUploadAccept(); controller.onUploadAccept().then(() => {
expect(controller.newPhoto.blob).toEqual('blobFile');
expect(controller.makeRequest).toHaveBeenCalledWith(); expect(controller.makeRequest).toHaveBeenCalledWith();
done();
}).catch(done.fail);
}); });
}); });
@ -44,7 +112,11 @@ describe('Salix', () => {
jest.spyOn(controller.vnApp, 'showSuccess'); jest.spyOn(controller.vnApp, 'showSuccess');
jest.spyOn(controller, 'emit'); jest.spyOn(controller, 'emit');
$httpBackend.expectGET('ImageContainers/allowedContentTypes').respond(200, ['image/jpg']);
controller.newPhoto.files = [{name: 'hola'}]; controller.newPhoto.files = [{name: 'hola'}];
controller.newPhoto.blob = new Blob([]);
$httpBackend.expectRoute('POST', 'Images/upload').respond(200); $httpBackend.expectRoute('POST', 'Images/upload').respond(200);
controller.makeRequest(); controller.makeRequest();
$httpBackend.flush(); $httpBackend.flush();

View File

@ -1,3 +1,6 @@
Upload new photo: Subir una nueva foto Edit photo: Editar foto
Select an image: Selecciona una imagen Select an image: Selecciona una imagen
File name: Nombre del fichero File name: Nombre del fichero
Rotate left: Girar a la izquierda
Rotate right: Girar a la derecha
Panoramic: Panorámico

View File

@ -1,24 +1,9 @@
@import "./variables"; @import "./variables";
.upload-photo { .upload-photo {
.photo {
position: relative;
margin: 0 auto;
text-align: center;
& > div { & > vn-horizontal {
border: 3px solid $color-primary; align-items: initial;
max-width: 256px;
max-height: 256px;
border-radius: 50%;
overflow: hidden
}
& > div > img[ng-src] {
width: 256px;
height: 256px;
display: block
}
} }
& > vn-spinner { & > vn-spinner {
@ -28,10 +13,13 @@
} }
vn-input-file { vn-input-file {
max-width: 256px;
div.control { div.control {
overflow: hidden overflow: hidden
} }
} }
.form {
align-items: initial;
}
} }