⬆️ ⬇️

Simple image editor on VueJS

Recently, I had the opportunity to write a service for an online store that would help place an order for printing my photos.



The service assumed the presence of a “simple” image editor, the creation of which I would like to share. And all because among the abundance of all sorts of plug-ins, I did not find the right functionality, and besides, the nuances of CSS transformations, suddenly became a very nontrivial task for me.



image



Main tasks:



  1. The ability to download images from the device, Google Drive and Instagram.
  2. Image editing: moving, rotating, flashing horizontally and vertically, zooming, auto-aligning the image to fill the crop area.


If the topic turns out to be interesting, in the next post I will describe in detail the integration with Google Drive and Instagram in the backend-part of the application, where the popular bundle NodeJS + Express was used.

')

For the organization of the frontend-a, I chose a wonderful framework Vue. Just because he inspires me after heavy Angular and annoying React. I think it makes no sense to describe the architecture, routes and other components, let's go straight to the editor.



By the way, you can poke the editor's demo here .



We need two components:



Edit - will contain the main logic and controls

Preview - will be responsible for displaying pictures



Edit component template:
<Edit> <Preview v-if="image" ref="preview" :matrix="matrix" :image="image" :transform="transform" @resized="areaResized" @loaded="imageLoaded" @moved="imageMoved" /> <input type="range" :min="minZoom" :max="maxZoom" step="any" @change="onZoomEnd" v-model.number="transform.zoom" :disabled="!imageReady" /> <button @click="rotateMinus" :disabled="!imageReady">Rotate left</button> <button @click="rotatePlus" :disabled="!imageReady">Rotate right</button> <button @click="flipY" :disabled="!imageReady">Flip horizontal</button> <button @click="flipX" :disabled="!imageReady">Flip vertical</button> </Edit> 




The Preview component can trigger 3 events:



loaded - image load event

resized - window resizing event

moved - picture move event



Options:



image - image link

matrix - transformation matrix for CSS transform properties

transform - an object that describes the transformation



In order to better control the position of the image, img has absolute positioning, and the transform-origin property, the transformation reference point, is set to the initial value “0 0”, which corresponds to the origin in the upper left corner of the original (before transformation!) Image.



The main problem I encountered is that you need to ensure that the transform-origin point is always in the center of the editing area, otherwise, during transformations, the selected part of the image will shift. This task can be solved using the transformation matrix .



Component Edit



Properties of the Edit Component:
 export default { components: { Preview }, data () { return { image: null, imageReady: false, imageRect: {}, //   areaRect: {}, //   minZoom: 1, //   maxZoom: 1, //   //   transform: { center: { x: 0, y: 0, }, zoom: 1, rotate: 0, flip: false, flop: false, x: 0, y: 0 } } }, computed: { matrix() { let scaleX = this.transform.flop ? -this.transform.zoom : this.transform.zoom; let scaleY = this.transform.flip ? -this.transform.zoom : this.transform.zoom; let tx = this.transform.x; let ty = this.transform.y; const cos = Math.cos(this.transform.rotate * Math.PI / 180); const sin = Math.sin(this.transform.rotate * Math.PI / 180); let a = Math.round(cos)*scaleX; let b = Math.round(sin)*scaleX; let c = -Math.round(sin)*scaleY; let d = Math.round(cos)*scaleY; return { a, b, c, d, tx, ty }; } }, ... } 




The imageRect and areaRect values ​​are passed to the Preview component by calling the imageLoaded and areaResized methods, respectively, the objects have the structure:



 { size: { width: 100, height: 100 }, center: { x: 50, y: 50 } } 


The center values ​​could be calculated each time, but it is easier to write them once.



The calculated matrix property is the very coefficients of the transformation matrix.



The first task that needs to be solved is to center an image with an arbitrary aspect ratio in the cropping area, while the image must be able to fit completely, blank areas (only) above and below, or (only) left and right are valid. For any transformation, this condition must be maintained.



First, we limit the values ​​for zooming, for this we will check the aspect ratio, taking into account the orientation of the image.



Component methods:
  _setMinZoom(){ let rotate = this.matrix.c !== 0; let horizontal = this.imageRect.size.height < this.imageRect.size.width; let areaSize = (horizontal && !rotate || !horizontal && rotate) ? this.areaRect.size.width : this.areaRect.size.height; let imageSize = horizontal ? this.imageRect.size.width : this.imageRect.size.height; this.minZoom = areaSize/imageSize; if(this.transform.zoom < this.minZoom) this.transform.zoom = this.minZoom; }, _setMaxZoom(){ this.maxZoom = this.areaRect.size.width/config.image.minResolution; if(this.transform.zoom > this.maxZoom) this.transform.zoom = this.maxZoom; }, 




We now turn to transformations. First, let's describe the reflections, because they do not displace the visible area of ​​the image.



Component methods:
 flipX(){ this.matrix.b == 0 && this.matrix.c == 0 ? this.transform.flip = !this.transform.flip : this.transform.flop = !this.transform.flop; }, flipY(){ this.matrix.b == 0 && this.matrix.c == 0 ? this.transform.flop = !this.transform.flop : this.transform.flip = !this.transform.flip; }, 




Transformations of zooming, rotation, and offset already require a transform-origin adjustment.



Component methods:
 onZoomEnd(){ this._translate(); }, rotatePlus(){ this.transform.rotate += 90; this._setMinZoom(); this._translate(); }, rotateMinus(){ this.transform.rotate -= 90; this._setMinZoom(); this._translate(); }, imageMoved(translate){ this._translate(); }, 




It is the _translate method that is responsible for all the subtleties of transformations. It is necessary to present two reference systems. The first one, let's call it zero, begins in the upper left corner of the image, when multiplying the coordinates by the transformation matrix, we move to another system of coordinates, let's call it local. In this case, the inverse transition, from local to zero, we can accomplish by finding the inverse transformation matrix .



So we need two functions.



The first is to go from zero to the local system; the browser performs the same transformation when we specify the transform css property.



 img { transform: matrix(a, b, c, d, tx, ty); } 


The second is for finding the original coordinates of the image, having already transformed coordinates.



The most convenient way is to write these functions using methods of a separate class.



Transform class:
 class Transform { constructor(center, matrix){ this.init(center, matrix); } init(center, matrix){ if(center) this.center = Object.assign({},center); if(matrix) this.matrix = Object.assign({},matrix); } getOrigins(current){ //     let tr = {x: current.x - this.center.x, y: current.y - this.center.y}; //         const det = 1/(this.matrix.a*this.matrix.d - this.matrix.c*this.matrix.b); const x = ( this.matrix.d*(tr.x - this.matrix.tx) - this.matrix.c*(tr.y - this.matrix.ty) ) * det + this.center.x; const y = (-this.matrix.b*(tr.x - this.matrix.tx) + this.matrix.a*(tr.y - this.matrix.ty) ) * det + this.center.y; return {x, y}; } translate(current){ //     const origin = {x: current.x - this.center.x, y: current.y - this.center.y}; //        let x = this.matrix.a*origin.x + this.matrix.c*origin.y + this.matrix.tx + this.center.x; let y = this.matrix.b*origin.x + this.matrix.d*origin.y + this.matrix.ty + this.center.y; return {x, y}; } } 




The _translate method with detailed comments:
 _translate(checkAlign = true){ const tr = new Transform(this.transform.center, this.matrix); // , ,  ,       const newCenter = tr.getOrigins(this.areaRect.center); this.transform.center = newCenter; //      this.transform.x = this.areaRect.center.x - newCenter.x; this.transform.y = this.areaRect.center.y - newCenter.y; //   tr.init(this.transform.center, this.matrix); //        ,      let x0y0 = tr.translate({x: 0, y: 0}); let x1y1 = tr.translate({x: this.imageRect.size.width, y: this.imageRect.size.height}); //  (  )       let result = { left: x1y1.x - x0y0.x > 0 ? x0y0.x : x1y1.x, top: x1y1.y - x0y0.y > 0 ? x0y0.y : x1y1.y, width: Math.abs(x1y1.x - x0y0.x), height: Math.abs(x1y1.y - x0y0.y) }; //       ,   "" let rightOffset = this.areaRect.size.width - (result.left + result.width); let bottomOffset = this.areaRect.size.height - (result.top + result.height); let alignedCenter; //   if(this.areaRect.size.width - result.width > 1){ //align center X alignedCenter = tr.getOrigins({x: result.left + result.width/2, y: this.areaRect.center.y}); }else{ //align left if(result.left > 0){ alignedCenter = tr.getOrigins({x: result.left + this.areaRect.center.x, y: this.areaRect.center.y}); //align right }else if(rightOffset > 0){ alignedCenter = tr.getOrigins({x: this.areaRect.center.x - rightOffset, y: this.areaRect.center.y}); } } if(alignedCenter){ this.transform.center = alignedCenter; this.transform.x = this.areaRect.center.x - alignedCenter.x; this.transform.y = this.areaRect.center.y - alignedCenter.y; tr.init(this.transform.center, this.matrix); } //   if(this.areaRect.size.height - result.height > 1){ //align center Y alignedCenter = tr.getOrigins({x: this.areaRect.center.x, y: result.top + result.height/2}); }else{ //align top if(result.top > 0){ alignedCenter = tr.getOrigins({x: this.areaRect.center.x, y: result.top + this.areaRect.center.y}); //align bottom }else if(bottomOffset > 0){ alignedCenter = tr.getOrigins({x: this.areaRect.center.x, y: this.areaRect.center.y - bottomOffset}); } } if(alignedCenter){ this.transform.center = alignedCenter; this.transform.x = this.areaRect.center.x - alignedCenter.x; this.transform.y = this.areaRect.center.y - alignedCenter.y; tr.init(this.transform.center, this.matrix); } }, 




Alignment creates the effect of "sticking" the image to the edges of the cropping area, avoiding empty fields.



Component Preview



The main task of this component is to display a picture, apply transformations and react to the movement of a mouse button clamped over an image. By calculating the offset, we update the transform.x and transform.y parameters; when the movement is completed, we trigger the moved event, telling the Edit component that we need to re-calculate the position of the transformation center and correct the transform.x and transform.y.



Preview component template:
<div ref = "area" class = "edit-zone"

@ mousedown = "onMoveStart"

@ touchstart = "onMoveStart"

mouseup = "onMoveEnd"

@ touchend = "onMoveEnd"

@ mousemove = "onMove"

@ touchmove = "onMove">

<img

v-if = "image"

ref = "image"

load = "imageLoaded"

: src = "image"

: style = "{'transform': transformStyle, 'transform-origin': transformOrigin}">



The functionality of the editor is neatly separated from the main project and lies here .



I hope this material will be useful for you. Thank!

Source: https://habr.com/ru/post/417697/



All Articles