├── .bowerrc ├── .gitignore ├── README.md ├── bower.json ├── dist ├── jr-crop.css ├── jr-crop.js └── jr-crop.min.js ├── example.jpg ├── examples ├── app.js ├── images │ ├── circle-mask.svg │ ├── kitten_1.jpeg │ ├── kitten_2.jpeg │ ├── kitten_3.jpeg │ ├── kitten_4.jpeg │ └── kitten_5.jpeg ├── index.html └── uploads │ └── .gitempty ├── gulpfile.js ├── package.json └── src ├── jr-crop.js └── jr-crop.scss /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .sass-cache 3 | node_modules 4 | bower -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | jr-crop 2 | ====== 3 | 4 | A simple ionic plugin to crop your images, inspired by whatsapp and telegram. 5 | * Specifiy width and height of target 6 | * Doesn't actually scale the image, only returns a cropped version. Since the quality of images while scaling is inconsistent it's up to the developper to implement this, preferably on the server. 7 | * Returns a canvas element with the new cropped image. 8 | 9 | ![example](/example.jpg) 10 | 11 | ## Simple enough, let's get started. 12 | 13 | Install the files: `bower install jr-crop --save`. 14 | 15 | Import the static files jr-crop.js and jr-crop.css. Declare jrCrop as a dependency. 16 | ``` 17 | .module('myModule', ['ionic', 'jrCrop']) 18 | ``` 19 | Inject jr-crop. 20 | ``` 21 | .controller('MyController', function($jrCrop) { 22 | ``` 23 | 24 | Call the crop function to open a new modal where the user can crop this image. Pass in the image url and targetsize. The function will return a promise that resolves when the user is done or fails when the user cancels. 25 | ``` 26 | $jrCrop.crop({ 27 | url: url, 28 | width: 200, 29 | height: 200 30 | }).then(function(canvas) { 31 | // success! 32 | var image = canvas.toDataURL(); 33 | }, function() { 34 | // User canceled or couldn't load image. 35 | }); 36 | ``` 37 | 38 | ##### Changing the title 39 | Additionally you can add a title in the footer. 40 | ``` 41 | $jrCrop.crop({ 42 | url: url, 43 | width: 200, 44 | height: 200, 45 | title: 'Move and Scale' 46 | }); 47 | ``` 48 | 49 | ##### Circle mask 50 | Add circle:true to the options to overlay the image with a circle. Note: it won't actually crop the image with a circle, just the visual representation. 51 | ``` 52 | $jrCrop.crop({ 53 | url: url, 54 | circle: true 55 | }); 56 | ``` 57 | 58 | ##### Changing default options. 59 | Overwriting default options can be done as well. 60 | `$jrCrop.defaultOptions.template = '
...
';` 61 | `$jrCrop.defaultOptions.width = 300;` 62 | `$jrCrop.defaultOptions.circle = true;` 63 | 64 | #### Templates 65 | 66 | ##### Custom templates 67 | The template can be overwritten by passing your custom HTML in the options. 68 | ``` 69 | $jrCrop.crop({ 70 | url: url, 71 | width: 200, 72 | height: 200, 73 | template: '
...
' 74 | }); 75 | ``` 76 | 77 | ##### Interpolation Markup 78 | If you configured the expressions of interpolated strings, you can apply this to the template by replacing the markup with your custom markup. 79 | ``` 80 | $jrCrop.defaultOptions.template = $jrCrop.defaultOptions.template 81 | .replace(/{{/g, '<%') 82 | .replace(/}}/g, '%>'); 83 | ``` 84 | 85 | ## Examples please!! 86 | I got ya. Run `bower install && npm install && npm test` and visit `localhost:8181`. Great, now you can visit this from your phone too. It works best when packaged in cordova, as how you should use ionic anyway. 87 | 88 | ## Support 89 | Though I'm only supporting iOS, I did get reports that it's working well on Android. If it doesn't, feel free to fork and update my codebase. If you just want to leave your thoughts you can reply in the [ionic forum topic](http://forum.ionicframework.com/t/sharing-my-photo-crop-plugin/4961). 90 | 91 | ## Contributing 92 | Open an issue or create a pull request. Please exclude the /dist files from your pull request. 93 | 94 | ## Release History 95 | * 2015-11-13  v1.1.2   Overwrite the template through options. Documentation on defaultOptions. 96 | * 2015-11-12   v1.1.1   Circle mask should not be shown by default. 97 | * 2015-11-12   v1.1.0   Add `circle` option to overlay the image with a circle mask. 98 | * 2015-04-05   v1.0.0   Breaking: jr-crop is now its own module, import it first. Support ionic v1.0.0 release candidate. Setting options will no longer overwrite the default options. 99 | * 2015-01-04   v0.1.1   Customize Cancel and Choose text. 100 | * 2014-12-14   v0.1.0   Release on bower, move from grunt to gulp, version numbering in header. Clean up examples and test server. Place the image in the center on initializing. Add maximum scale option. Hide picture overflow in modal at bigger viewport. Add example pictures as static files rather than from url. -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jr-crop", 3 | "version": "1.1.2", 4 | "homepage": "https://github.com/JrSchild/jr-crop", 5 | "authors": [ 6 | "Joram Ruitenschild" 7 | ], 8 | "description": "A simple ionic plugin to crop your images.", 9 | "main": [ 10 | "dist/jr-crop.css", 11 | "dist/jr-crop.min.js" 12 | ], 13 | "keywords": [ 14 | "jr-crop", 15 | "crop", 16 | "ionic", 17 | "ionic-crop" 18 | ], 19 | "license": "MIT", 20 | "devDependencies": { 21 | "ionic": "1.1.1", 22 | "blueimp-canvas-to-blob": "~2.2.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /dist/jr-crop.css: -------------------------------------------------------------------------------- 1 | /** 2 | * jr-crop - A simple ionic plugin to crop your images. 3 | * @version 1.1.2 4 | * @link https://github.com/JrSchild/jr-crop 5 | * @author Joram Ruitenschild 6 | * @license MIT 7 | */ 8 | 9 | .jr-crop { 10 | background-color: #000; 11 | overflow: hidden; } 12 | 13 | .jr-crop-center-container { 14 | display: -webkit-box; 15 | display: -webkit-flex; 16 | display: -moz-box; 17 | display: -moz-flex; 18 | display: -ms-flexbox; 19 | display: flex; 20 | -webkit-box-pack: center; 21 | -ms-flex-pack: center; 22 | -webkit-justify-content: center; 23 | -moz-justify-content: center; 24 | justify-content: center; 25 | -webkit-box-align: center; 26 | -ms-flex-align: center; 27 | -webkit-align-items: center; 28 | -moz-align-items: center; 29 | align-items: center; 30 | position: absolute; 31 | width: 100%; 32 | height: 100%; } 33 | 34 | .jr-crop-img { 35 | opacity: 0.6; } 36 | 37 | .bar.jr-crop-footer { 38 | border: none; 39 | background-color: transparent; 40 | background-image: none; } 41 | 42 | .jr-crop-select.jr-crop-select-circle { 43 | -webkit-mask-box-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTAwcHgiIGhlaWdodD0iMTAwcHgiIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMDAgMTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+DQo8L3N2Zz4=); 44 | mask-box-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTAwcHgiIGhlaWdodD0iMTAwcHgiIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMDAgMTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+DQo8L3N2Zz4=); } 45 | -------------------------------------------------------------------------------- /dist/jr-crop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jr-crop - A simple ionic plugin to crop your images. 3 | * @version 1.1.2 4 | * @link https://github.com/JrSchild/jr-crop 5 | * @author Joram Ruitenschild 6 | * @license MIT 7 | */ 8 | 9 | angular.module('jrCrop', ['ionic']) 10 | 11 | .factory('$jrCrop', [ 12 | '$ionicModal', 13 | '$rootScope', 14 | '$q', 15 | function($ionicModal, $rootScope, $q) { 16 | 17 | var template = ''; 30 | 31 | var jrCropController = ionic.views.View.inherit({ 32 | 33 | promise: null, 34 | imgWidth: null, 35 | imgHeight: null, 36 | 37 | // Elements that hold the cropped version and the full 38 | // overlaying image. 39 | imgSelect: null, 40 | imgFull: null, 41 | 42 | // Values exposed by scaling and moving. Needed 43 | // to calculate the result of cropped image 44 | posX: 0, 45 | posY: 0, 46 | scale: 1, 47 | 48 | last_scale: 1, 49 | last_posX: 0, 50 | last_posY: 0, 51 | 52 | initialize: function(options) { 53 | var self = this; 54 | 55 | self.options = options; 56 | self.promise = $q.defer(); 57 | self.loadImage().then(function(elem) { 58 | self.imgWidth = elem.naturalWidth; 59 | self.imgHeight = elem.naturalHeight; 60 | 61 | self.options.modal.el.querySelector('.jr-crop-img').appendChild(self.imgSelect = elem.cloneNode()); 62 | self.options.modal.el.querySelector('.jr-crop-select').appendChild(self.imgFull = elem.cloneNode()); 63 | 64 | self.bindHandlers(); 65 | self.initImage(); 66 | }); 67 | 68 | // options === scope. Expose actions for modal. 69 | self.options.cancel = this.cancel.bind(this); 70 | self.options.crop = this.crop.bind(this); 71 | }, 72 | 73 | /** 74 | * Init the image in a center position 75 | */ 76 | initImage: function() { 77 | if (this.options.height < this.imgHeight || this.options.width < this.imgWidth) { 78 | var imgAspectRatio = this.imgWidth / this.imgHeight; 79 | var selectAspectRatio = this.options.width / this.options.height; 80 | 81 | if (selectAspectRatio > imgAspectRatio) { 82 | this.scale = this.last_scale = this.options.width / this.imgWidth; 83 | } else { 84 | this.scale = this.last_scale = this.options.height / this.imgHeight; 85 | } 86 | } 87 | 88 | var centerX = (this.imgWidth - this.options.width) / 2; 89 | var centerY = (this.imgHeight - this.options.height) / 2; 90 | 91 | this.posX = this.last_posX = -centerX; 92 | this.posY = this.last_posY = -centerY; 93 | 94 | this.setImageTransform(); 95 | }, 96 | 97 | cancel: function() { 98 | var self = this; 99 | 100 | self.options.modal.remove().then(function() { 101 | self.promise.reject('canceled'); 102 | }); 103 | }, 104 | 105 | /** 106 | * This is where the magic happens 107 | */ 108 | bindHandlers: function() { 109 | var self = this, 110 | 111 | min_pos_x = 0, min_pos_y = 0, 112 | max_pos_x = 0, max_pos_y = 0, 113 | transforming_correctX = 0, transforming_correctY = 0, 114 | 115 | scaleMax = 1, scaleMin, 116 | image_ratio = self.imgWidth / self.imgHeight, 117 | cropper_ratio = self.options.width / self.options.height; 118 | 119 | if (cropper_ratio < image_ratio) { 120 | scaleMin = self.options.height / self.imgHeight; 121 | } else { 122 | scaleMin = self.options.width / self.imgWidth; 123 | } 124 | 125 | function setPosWithinBoundaries() { 126 | calcMaxPos(); 127 | if (self.posX > min_pos_x) { 128 | self.posX = min_pos_x; 129 | } 130 | if (self.posX < max_pos_x) { 131 | self.posX = max_pos_x; 132 | } 133 | if (self.posY > min_pos_y) { 134 | self.posY = min_pos_y; 135 | } 136 | if (self.posY < max_pos_y) { 137 | self.posY = max_pos_y; 138 | } 139 | } 140 | 141 | /** 142 | * Calculate the minimum and maximum positions. 143 | * This took some headaches to write, good luck 144 | * figuring this out. 145 | */ 146 | function calcMaxPos() { 147 | // Calculate current proportions of the image. 148 | var currWidth = self.scale * self.imgWidth; 149 | var currHeight = self.scale * self.imgHeight; 150 | 151 | // Images are scaled from the center 152 | min_pos_x = (currWidth - self.imgWidth) / 2; 153 | min_pos_y = (currHeight - self.imgHeight) / 2; 154 | max_pos_x = -(currWidth - min_pos_x - self.options.width); 155 | max_pos_y = -(currHeight - min_pos_y - self.options.height); 156 | } 157 | 158 | // Based on: http://stackoverflow.com/questions/18011099/pinch-to-zoom-using-hammer-js 159 | var options = { 160 | prevent_default_directions: ['left','right', 'up', 'down'] 161 | }; 162 | ionic.onGesture('touch transform drag dragstart dragend', function(e) { 163 | switch (e.type) { 164 | case 'touch': 165 | self.last_scale = self.scale; 166 | break; 167 | case 'drag': 168 | self.posX = self.last_posX + e.gesture.deltaX - transforming_correctX; 169 | self.posY = self.last_posY + e.gesture.deltaY - transforming_correctY; 170 | setPosWithinBoundaries(); 171 | break; 172 | case 'transform': 173 | self.scale = Math.max(scaleMin, Math.min(self.last_scale * e.gesture.scale, scaleMax)); 174 | setPosWithinBoundaries(); 175 | break; 176 | case 'dragend': 177 | self.last_posX = self.posX; 178 | self.last_posY = self.posY; 179 | break; 180 | case 'dragstart': 181 | self.last_scale = self.scale; 182 | 183 | // After scaling, hammerjs needs time to reset the deltaX and deltaY values, 184 | // when the user drags the image before this is done the image will jump. 185 | // This is an attempt to fix that. 186 | if (e.gesture.deltaX > 1 || e.gesture.deltaX < -1) { 187 | transforming_correctX = e.gesture.deltaX; 188 | transforming_correctY = e.gesture.deltaY; 189 | } else { 190 | transforming_correctX = 0; 191 | transforming_correctY = 0; 192 | } 193 | break; 194 | } 195 | 196 | self.setImageTransform(); 197 | 198 | }, self.options.modal.el, options); 199 | }, 200 | 201 | setImageTransform: function() { 202 | var self = this; 203 | 204 | var transform = 205 | 'translate3d(' + self.posX + 'px,' + self.posY + 'px, 0) ' + 206 | 'scale3d(' + self.scale + ',' + self.scale + ', 1)'; 207 | 208 | self.imgSelect.style[ionic.CSS.TRANSFORM] = transform; 209 | self.imgFull.style[ionic.CSS.TRANSFORM] = transform; 210 | }, 211 | 212 | /** 213 | * Calculate the new image from the values calculated by 214 | * user input. Return a canvas-object with the image on it. 215 | * 216 | * Note: It doesn't actually downsize the image, it only returns 217 | * a cropped version. Since there's inconsistenties in image-quality 218 | * when downsizing it's up to the developer to implement this. Preferably 219 | * on the server. 220 | */ 221 | crop: function() { 222 | var canvas = document.createElement('canvas'); 223 | var context = canvas.getContext('2d'); 224 | 225 | // Canvas size is original proportions but scaled down. 226 | canvas.width = this.options.width / this.scale; 227 | canvas.height = this.options.height / this.scale; 228 | 229 | // The full proportions 230 | var currWidth = this.imgWidth * this.scale; 231 | var currHeight = this.imgHeight * this.scale; 232 | 233 | // Because the top/left position doesn't take the scale of the image in 234 | // we need to correct this value. 235 | var correctX = (currWidth - this.imgWidth) / 2; 236 | var correctY = (currHeight - this.imgHeight) / 2; 237 | 238 | var sourceX = (this.posX - correctX) / this.scale; 239 | var sourceY = (this.posY - correctY) / this.scale; 240 | 241 | context.drawImage(this.imgFull, sourceX, sourceY); 242 | 243 | this.options.modal.remove(); 244 | this.promise.resolve(canvas); 245 | }, 246 | 247 | /** 248 | * Load the image and return the element. 249 | * Return Promise that will fail when unable to load image. 250 | */ 251 | loadImage: function() { 252 | var promise = $q.defer(); 253 | 254 | // Load the image and resolve with the DOM node when done. 255 | angular.element('') 256 | .bind('load', function(e) { 257 | promise.resolve(this); 258 | }) 259 | .bind('error', promise.reject) 260 | .prop('src', this.options.url); 261 | 262 | // Return the promise 263 | return promise.promise; 264 | } 265 | }); 266 | 267 | return { 268 | defaultOptions: { 269 | width: 0, 270 | height: 0, 271 | aspectRatio: 0, 272 | cancelText: 'Cancel', 273 | chooseText: 'Choose', 274 | template: template, 275 | circle: false 276 | }, 277 | 278 | crop: function(options) { 279 | options = this.initOptions(options); 280 | 281 | var scope = $rootScope.$new(true); 282 | 283 | ionic.extend(scope, options); 284 | 285 | scope.modal = $ionicModal.fromTemplate(options.template, { 286 | scope: scope 287 | }); 288 | 289 | // Show modal and initialize cropper. 290 | return scope.modal.show().then(function() { 291 | return (new jrCropController(scope)).promise.promise; 292 | }); 293 | }, 294 | 295 | initOptions: function(_options) { 296 | var options; 297 | 298 | // Apply default values to options. 299 | options = ionic.extend({}, this.defaultOptions); 300 | options = ionic.extend(options, _options); 301 | 302 | if (options.aspectRatio) { 303 | 304 | if (!options.width && options.height) { 305 | options.width = 200; 306 | } 307 | 308 | if (options.width) { 309 | options.height = options.width / options.aspectRatio; 310 | } else if (options.height) { 311 | options.width = options.height * options.aspectRatio; 312 | } 313 | } 314 | 315 | return options; 316 | } 317 | }; 318 | }]); -------------------------------------------------------------------------------- /dist/jr-crop.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jr-crop - A simple ionic plugin to crop your images. 3 | * @version 1.1.2 4 | * @link https://github.com/JrSchild/jr-crop 5 | * @author Joram Ruitenschild 6 | * @license MIT 7 | */ 8 | 9 | angular.module("jrCrop",["ionic"]).factory("$jrCrop",["$ionicModal","$rootScope","$q",function(t,i,e){var s='',o=ionic.views.View.inherit({promise:null,imgWidth:null,imgHeight:null,imgSelect:null,imgFull:null,posX:0,posY:0,scale:1,last_scale:1,last_posX:0,last_posY:0,initialize:function(t){var i=this;i.options=t,i.promise=e.defer(),i.loadImage().then(function(t){i.imgWidth=t.naturalWidth,i.imgHeight=t.naturalHeight,i.options.modal.el.querySelector(".jr-crop-img").appendChild(i.imgSelect=t.cloneNode()),i.options.modal.el.querySelector(".jr-crop-select").appendChild(i.imgFull=t.cloneNode()),i.bindHandlers(),i.initImage()}),i.options.cancel=this.cancel.bind(this),i.options.crop=this.crop.bind(this)},initImage:function(){if(this.options.heightt?this.scale=this.last_scale=this.options.width/this.imgWidth:this.scale=this.last_scale=this.options.height/this.imgHeight}var e=(this.imgWidth-this.options.width)/2,s=(this.imgHeight-this.options.height)/2;this.posX=this.last_posX=-e,this.posY=this.last_posY=-s,this.setImageTransform()},cancel:function(){var t=this;t.options.modal.remove().then(function(){t.promise.reject("canceled")})},bindHandlers:function(){function t(){i(),s.posX>o&&(s.posX=o),s.posXa&&(s.posY=a),s.posYp?s.options.height/s.imgHeight:s.options.width/s.imgWidth;var g={prevent_default_directions:["left","right","up","down"]};ionic.onGesture("touch transform drag dragstart dragend",function(i){switch(i.type){case"touch":s.last_scale=s.scale;break;case"drag":s.posX=s.last_posX+i.gesture.deltaX-l,s.posY=s.last_posY+i.gesture.deltaY-c,t();break;case"transform":s.scale=Math.max(e,Math.min(s.last_scale*i.gesture.scale,r)),t();break;case"dragend":s.last_posX=s.posX,s.last_posY=s.posY;break;case"dragstart":s.last_scale=s.scale,i.gesture.deltaX>1||i.gesture.deltaX<-1?(l=i.gesture.deltaX,c=i.gesture.deltaY):(l=0,c=0)}s.setImageTransform()},s.options.modal.el,g)},setImageTransform:function(){var t=this,i="translate3d("+t.posX+"px,"+t.posY+"px, 0) scale3d("+t.scale+","+t.scale+", 1)";t.imgSelect.style[ionic.CSS.TRANSFORM]=i,t.imgFull.style[ionic.CSS.TRANSFORM]=i},crop:function(){var t=document.createElement("canvas"),i=t.getContext("2d");t.width=this.options.width/this.scale,t.height=this.options.height/this.scale;var e=this.imgWidth*this.scale,s=this.imgHeight*this.scale,o=(e-this.imgWidth)/2,a=(s-this.imgHeight)/2,n=(this.posX-o)/this.scale,h=(this.posY-a)/this.scale;i.drawImage(this.imgFull,n,h),this.options.modal.remove(),this.promise.resolve(t)},loadImage:function(){var t=e.defer();return angular.element("").bind("load",function(i){t.resolve(this)}).bind("error",t.reject).prop("src",this.options.url),t.promise}});return{defaultOptions:{width:0,height:0,aspectRatio:0,cancelText:"Cancel",chooseText:"Choose",template:s,circle:!1},crop:function(e){e=this.initOptions(e);var s=i.$new(!0);return ionic.extend(s,e),s.modal=t.fromTemplate(e.template,{scope:s}),s.modal.show().then(function(){return new o(s).promise.promise})},initOptions:function(t){var i;return i=ionic.extend({},this.defaultOptions),i=ionic.extend(i,t),i.aspectRatio&&(!i.width&&i.height&&(i.width=200),i.width?i.height=i.width/i.aspectRatio:i.height&&(i.width=i.height*i.aspectRatio)),i}}}]); -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/example.jpg -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var express = require('express'); 4 | var formidable = require('formidable'); 5 | var app = express(); 6 | 7 | app.use(express.static('./')); 8 | app.use(express.static('./examples')); 9 | 10 | var types = { 11 | 'image/jpeg': 'jpg', 12 | 'image/png': 'png' 13 | }; 14 | 15 | // Upload the file to memory. 16 | app.post('/upload', function (req, res) { 17 | 18 | // Parse the request. Any body-parser can be used for this. 19 | var form = new formidable.IncomingForm(); 20 | form.parse(req, function(err, fields, files) { 21 | 22 | // The key was set as 'image' on the formData object in the client. 23 | var file = files.image; 24 | 25 | if (!file) { 26 | return res.status(400).send('No image received'); 27 | } 28 | 29 | var extension = types[file.type.toLowerCase()]; 30 | 31 | // Make sure the extension is valid. 32 | if (!extension) { 33 | return res.status(400).send('Invalid file type'); 34 | } 35 | 36 | // Target path is uploads directory relative to this file. 37 | var target = path.resolve(__dirname, 'uploads', Date.now() + '.' + extension); 38 | 39 | // Copy the file from the tmp folder to the uploads location 40 | fs.createReadStream(file.path) 41 | .pipe(fs.createWriteStream(target)); 42 | 43 | res.status(200).send(); 44 | }); 45 | }); 46 | 47 | var server = app.listen(8181, function () { 48 | console.log('Running jr-crop example server. Visit http://localhost:8181'); 49 | }); -------------------------------------------------------------------------------- /examples/images/circle-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /examples/images/kitten_1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/images/kitten_1.jpeg -------------------------------------------------------------------------------- /examples/images/kitten_2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/images/kitten_2.jpeg -------------------------------------------------------------------------------- /examples/images/kitten_3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/images/kitten_3.jpeg -------------------------------------------------------------------------------- /examples/images/kitten_4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/images/kitten_4.jpeg -------------------------------------------------------------------------------- /examples/images/kitten_5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/images/kitten_5.jpeg -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | jr-crop demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 30 | 31 | 32 | 33 | 34 |
35 | 36 |

Ionic Delete/Option Buttons

37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 60 | 61 | 107 | 108 | -------------------------------------------------------------------------------- /examples/uploads/.gitempty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JrSchild/jr-crop/74536d46a2b66ed444b06b1ab3a11affd32edb00/examples/uploads/.gitempty -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var jshint = require('gulp-jshint'); 3 | var sass = require('gulp-sass'); 4 | var uglify = require('gulp-uglify'); 5 | var rename = require('gulp-rename'); 6 | var header = require('gulp-header'); 7 | var bower = require('./bower.json'); 8 | 9 | var banner = [ 10 | '/**', 11 | ' * <%= bower.name %> - <%= bower.description %>', 12 | ' * @version <%= bower.version %>', 13 | ' * @link <%= bower.homepage %>', 14 | ' * @author <%= bower.authors.join(", ") %>', 15 | ' * @license <%= bower.license %>', 16 | ' */', '', ''].join('\n'); 17 | 18 | gulp.task('lint', function () { 19 | return gulp.src('src/jr-crop.js') 20 | .pipe(jshint()) 21 | .pipe(jshint.reporter('default')); 22 | }); 23 | 24 | gulp.task('scripts', function () { 25 | return gulp.src('src/jr-crop.js') 26 | .pipe(header(banner, { bower: bower } )) 27 | .pipe(gulp.dest('dist')) 28 | .pipe(rename('jr-crop.min.js')) 29 | .pipe(uglify()) 30 | .pipe(header(banner, { bower: bower } )) 31 | .pipe(gulp.dest('dist')); 32 | }); 33 | 34 | gulp.task('style', function () { 35 | return gulp.src('src/jr-crop.scss') 36 | .pipe(sass()) 37 | .pipe(header(banner, { bower: bower } )) 38 | .pipe(gulp.dest('dist')); 39 | }); 40 | 41 | gulp.task('watch', function() { 42 | gulp.watch('src/**/*', ['default']); 43 | }); 44 | 45 | gulp.task('default', ['lint', 'scripts', 'style']); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jr-crop", 3 | "version": "1.1.2", 4 | "scripts": { 5 | "test": "node examples/app.js" 6 | }, 7 | "devDependencies": { 8 | "express": "^4.13.3", 9 | "formidable": "^1.0.17", 10 | "gulp": "^3.9.0", 11 | "gulp-concat": "^2.6.0", 12 | "gulp-header": "^1.7.1", 13 | "gulp-jshint": "^2.0.0", 14 | "gulp-rename": "^1.2.2", 15 | "gulp-sass": "^2.1.0", 16 | "gulp-uglify": "^1.5.1", 17 | "jshint": "^2.8.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/jr-crop.js: -------------------------------------------------------------------------------- 1 | angular.module('jrCrop', ['ionic']) 2 | 3 | .factory('$jrCrop', [ 4 | '$ionicModal', 5 | '$rootScope', 6 | '$q', 7 | function($ionicModal, $rootScope, $q) { 8 | 9 | var template = ''; 22 | 23 | var jrCropController = ionic.views.View.inherit({ 24 | 25 | promise: null, 26 | imgWidth: null, 27 | imgHeight: null, 28 | 29 | // Elements that hold the cropped version and the full 30 | // overlaying image. 31 | imgSelect: null, 32 | imgFull: null, 33 | 34 | // Values exposed by scaling and moving. Needed 35 | // to calculate the result of cropped image 36 | posX: 0, 37 | posY: 0, 38 | scale: 1, 39 | 40 | last_scale: 1, 41 | last_posX: 0, 42 | last_posY: 0, 43 | 44 | initialize: function(options) { 45 | var self = this; 46 | 47 | self.options = options; 48 | self.promise = $q.defer(); 49 | self.loadImage().then(function(elem) { 50 | self.imgWidth = elem.naturalWidth; 51 | self.imgHeight = elem.naturalHeight; 52 | 53 | self.options.modal.el.querySelector('.jr-crop-img').appendChild(self.imgSelect = elem.cloneNode()); 54 | self.options.modal.el.querySelector('.jr-crop-select').appendChild(self.imgFull = elem.cloneNode()); 55 | 56 | self.bindHandlers(); 57 | self.initImage(); 58 | }); 59 | 60 | // options === scope. Expose actions for modal. 61 | self.options.cancel = this.cancel.bind(this); 62 | self.options.crop = this.crop.bind(this); 63 | }, 64 | 65 | /** 66 | * Init the image in a center position 67 | */ 68 | initImage: function() { 69 | if (this.options.height < this.imgHeight || this.options.width < this.imgWidth) { 70 | var imgAspectRatio = this.imgWidth / this.imgHeight; 71 | var selectAspectRatio = this.options.width / this.options.height; 72 | 73 | if (selectAspectRatio > imgAspectRatio) { 74 | this.scale = this.last_scale = this.options.width / this.imgWidth; 75 | } else { 76 | this.scale = this.last_scale = this.options.height / this.imgHeight; 77 | } 78 | } 79 | 80 | var centerX = (this.imgWidth - this.options.width) / 2; 81 | var centerY = (this.imgHeight - this.options.height) / 2; 82 | 83 | this.posX = this.last_posX = -centerX; 84 | this.posY = this.last_posY = -centerY; 85 | 86 | this.setImageTransform(); 87 | }, 88 | 89 | cancel: function() { 90 | var self = this; 91 | 92 | self.options.modal.remove().then(function() { 93 | self.promise.reject('canceled'); 94 | }); 95 | }, 96 | 97 | /** 98 | * This is where the magic happens 99 | */ 100 | bindHandlers: function() { 101 | var self = this, 102 | 103 | min_pos_x = 0, min_pos_y = 0, 104 | max_pos_x = 0, max_pos_y = 0, 105 | transforming_correctX = 0, transforming_correctY = 0, 106 | 107 | scaleMax = 1, scaleMin, 108 | image_ratio = self.imgWidth / self.imgHeight, 109 | cropper_ratio = self.options.width / self.options.height; 110 | 111 | if (cropper_ratio < image_ratio) { 112 | scaleMin = self.options.height / self.imgHeight; 113 | } else { 114 | scaleMin = self.options.width / self.imgWidth; 115 | } 116 | 117 | function setPosWithinBoundaries() { 118 | calcMaxPos(); 119 | if (self.posX > min_pos_x) { 120 | self.posX = min_pos_x; 121 | } 122 | if (self.posX < max_pos_x) { 123 | self.posX = max_pos_x; 124 | } 125 | if (self.posY > min_pos_y) { 126 | self.posY = min_pos_y; 127 | } 128 | if (self.posY < max_pos_y) { 129 | self.posY = max_pos_y; 130 | } 131 | } 132 | 133 | /** 134 | * Calculate the minimum and maximum positions. 135 | * This took some headaches to write, good luck 136 | * figuring this out. 137 | */ 138 | function calcMaxPos() { 139 | // Calculate current proportions of the image. 140 | var currWidth = self.scale * self.imgWidth; 141 | var currHeight = self.scale * self.imgHeight; 142 | 143 | // Images are scaled from the center 144 | min_pos_x = (currWidth - self.imgWidth) / 2; 145 | min_pos_y = (currHeight - self.imgHeight) / 2; 146 | max_pos_x = -(currWidth - min_pos_x - self.options.width); 147 | max_pos_y = -(currHeight - min_pos_y - self.options.height); 148 | } 149 | 150 | // Based on: http://stackoverflow.com/questions/18011099/pinch-to-zoom-using-hammer-js 151 | var options = { 152 | prevent_default_directions: ['left','right', 'up', 'down'] 153 | }; 154 | ionic.onGesture('touch transform drag dragstart dragend', function(e) { 155 | switch (e.type) { 156 | case 'touch': 157 | self.last_scale = self.scale; 158 | break; 159 | case 'drag': 160 | self.posX = self.last_posX + e.gesture.deltaX - transforming_correctX; 161 | self.posY = self.last_posY + e.gesture.deltaY - transforming_correctY; 162 | setPosWithinBoundaries(); 163 | break; 164 | case 'transform': 165 | self.scale = Math.max(scaleMin, Math.min(self.last_scale * e.gesture.scale, scaleMax)); 166 | setPosWithinBoundaries(); 167 | break; 168 | case 'dragend': 169 | self.last_posX = self.posX; 170 | self.last_posY = self.posY; 171 | break; 172 | case 'dragstart': 173 | self.last_scale = self.scale; 174 | 175 | // After scaling, hammerjs needs time to reset the deltaX and deltaY values, 176 | // when the user drags the image before this is done the image will jump. 177 | // This is an attempt to fix that. 178 | if (e.gesture.deltaX > 1 || e.gesture.deltaX < -1) { 179 | transforming_correctX = e.gesture.deltaX; 180 | transforming_correctY = e.gesture.deltaY; 181 | } else { 182 | transforming_correctX = 0; 183 | transforming_correctY = 0; 184 | } 185 | break; 186 | } 187 | 188 | self.setImageTransform(); 189 | 190 | }, self.options.modal.el, options); 191 | }, 192 | 193 | setImageTransform: function() { 194 | var self = this; 195 | 196 | var transform = 197 | 'translate3d(' + self.posX + 'px,' + self.posY + 'px, 0) ' + 198 | 'scale3d(' + self.scale + ',' + self.scale + ', 1)'; 199 | 200 | self.imgSelect.style[ionic.CSS.TRANSFORM] = transform; 201 | self.imgFull.style[ionic.CSS.TRANSFORM] = transform; 202 | }, 203 | 204 | /** 205 | * Calculate the new image from the values calculated by 206 | * user input. Return a canvas-object with the image on it. 207 | * 208 | * Note: It doesn't actually downsize the image, it only returns 209 | * a cropped version. Since there's inconsistenties in image-quality 210 | * when downsizing it's up to the developer to implement this. Preferably 211 | * on the server. 212 | */ 213 | crop: function() { 214 | var canvas = document.createElement('canvas'); 215 | var context = canvas.getContext('2d'); 216 | 217 | // Canvas size is original proportions but scaled down. 218 | canvas.width = this.options.width / this.scale; 219 | canvas.height = this.options.height / this.scale; 220 | 221 | // The full proportions 222 | var currWidth = this.imgWidth * this.scale; 223 | var currHeight = this.imgHeight * this.scale; 224 | 225 | // Because the top/left position doesn't take the scale of the image in 226 | // we need to correct this value. 227 | var correctX = (currWidth - this.imgWidth) / 2; 228 | var correctY = (currHeight - this.imgHeight) / 2; 229 | 230 | var sourceX = (this.posX - correctX) / this.scale; 231 | var sourceY = (this.posY - correctY) / this.scale; 232 | 233 | context.drawImage(this.imgFull, sourceX, sourceY); 234 | 235 | this.options.modal.remove(); 236 | this.promise.resolve(canvas); 237 | }, 238 | 239 | /** 240 | * Load the image and return the element. 241 | * Return Promise that will fail when unable to load image. 242 | */ 243 | loadImage: function() { 244 | var promise = $q.defer(); 245 | 246 | // Load the image and resolve with the DOM node when done. 247 | angular.element('') 248 | .bind('load', function(e) { 249 | promise.resolve(this); 250 | }) 251 | .bind('error', promise.reject) 252 | .prop('src', this.options.url); 253 | 254 | // Return the promise 255 | return promise.promise; 256 | } 257 | }); 258 | 259 | return { 260 | defaultOptions: { 261 | width: 0, 262 | height: 0, 263 | aspectRatio: 0, 264 | cancelText: 'Cancel', 265 | chooseText: 'Choose', 266 | template: template, 267 | circle: false 268 | }, 269 | 270 | crop: function(options) { 271 | options = this.initOptions(options); 272 | 273 | var scope = $rootScope.$new(true); 274 | 275 | ionic.extend(scope, options); 276 | 277 | scope.modal = $ionicModal.fromTemplate(options.template, { 278 | scope: scope 279 | }); 280 | 281 | // Show modal and initialize cropper. 282 | return scope.modal.show().then(function() { 283 | return (new jrCropController(scope)).promise.promise; 284 | }); 285 | }, 286 | 287 | initOptions: function(_options) { 288 | var options; 289 | 290 | // Apply default values to options. 291 | options = ionic.extend({}, this.defaultOptions); 292 | options = ionic.extend(options, _options); 293 | 294 | if (options.aspectRatio) { 295 | 296 | if (!options.width && options.height) { 297 | options.width = 200; 298 | } 299 | 300 | if (options.width) { 301 | options.height = options.width / options.aspectRatio; 302 | } else if (options.height) { 303 | options.width = options.height * options.aspectRatio; 304 | } 305 | } 306 | 307 | return options; 308 | } 309 | }; 310 | }]); -------------------------------------------------------------------------------- /src/jr-crop.scss: -------------------------------------------------------------------------------- 1 | @import "../bower/ionic/scss/mixins"; 2 | 3 | @mixin maskBoxImage($value) { 4 | -webkit-mask-box-image: url(#{$value}); 5 | mask-box-image: url(#{$value}); 6 | } 7 | 8 | .jr-crop { 9 | background-color: #000; 10 | overflow: hidden; 11 | } 12 | 13 | .jr-crop-center-container { 14 | @include display-flex(); 15 | @include justify-content(center); 16 | @include align-items(center); 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | } 21 | 22 | .jr-crop-img { 23 | opacity: 0.6; 24 | } 25 | 26 | .bar.jr-crop-footer { 27 | border: none; 28 | background-color: transparent; 29 | background-image: none; 30 | } 31 | 32 | .jr-crop-select.jr-crop-select-circle { 33 | @include maskBoxImage("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTAwcHgiIGhlaWdodD0iMTAwcHgiIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMDAgMTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+DQo8L3N2Zz4="); 34 | } --------------------------------------------------------------------------------