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 kinds of plug-ins, I did not find the right functionality, moreover, the nuances of CSS transformations, suddenly became for me a very nontrivial task.

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, automatic alignment of 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 it 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
Preview controls - will be responsible for displaying the image

Edit component template:
<Edit><Previewv-if="image"ref="preview":matrix="matrix":image="image":transform="transform" 
        @resized="areaResized" 
        @loaded="imageLoaded" 
        @moved="imageMoved" /><inputtype="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 loading event
resized - window resizing event
moved - image moving event

Parameters:

image - image link
matrix - transformation matrix for CSS property transform
transform - object that describes transformations

For better control over image position, img has absolute positioning, and the property of transform-origin , the reference point of transformation, set the initial value "0 0", which corresponds to the origin in the upper left corner of the original (before transformation!) and siderations.

The main problem I encountered is 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:
exportdefault {
    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. In 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 will 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 local system, the same transformations are performed by the browser 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 with the methods of a separate class.

Transform class:
classTransform{
    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 leftif(result.left > 0){
        alignedCenter = tr.getOrigins({x: result.left + this.areaRect.center.x, y: this.areaRect.center.y});
        //align right
        }elseif(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 topif(result.top > 0){
        alignedCenter = tr.getOrigins({x: this.areaRect.center.x, y: result.top + this.areaRect.center.y});
        //align bottom
        }elseif(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, and 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!

Also popular now: