Простой редактор изображений на VueJS

в 9:51, , рубрики:  редактор изображений, css, image editor, javascript, transform, vue.js, vuejs, обработка изображений

Недавно мне выпала возможность написать сервис для интернет-магазина, который помогал бы оформить заказ на печать своих фото.

Сервис предполагал наличие «простого» редактора изображений, созданием которого, я бы хотел поделиться. А все потому, что среди обилия всевозможных плагинов я не нашел подходящего функционала, к тому же, нюансы CSS трансформаций, неожиданно стали для меня весьма нетривиальной задачей.

image

Основные задачи:

  1. Возможность загрузки изображений с устройства, Google Drive и Instagram.
  2. Редактирование изображения: перемещение, вращение, отражение по горизонтали и вертикали, зуммирование, автоматическое выравнивание изображения для заполнения области кропа.

Если тема окажется интересной, в следующей публикации я детально опишу интеграцию с Google Drive и Instagram в backend-части приложения, где была использована популярная связка NodeJS+Express.

Для организации frontend-a я выбрал замечательный фреймворк Vue. Просто потому что он меня вдохновляет после тяжелого Angular и надоевшего React. Думаю, нет смысла описывать архитектуру, роуты и прочие компоненты, перейдем сразу к редактору.

Кстати, демку редактора можно потыкать здесь.

Нам понадобится два компонента:

Edit — будет содержать основную логику и элементы управления
Preview — будет отвечать за отображение картинки

Шаблон компонента Edit:

<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>

Компонент Preview может тригерить 3 события:

loaded — событие загрузки изображения
resized — событие изменения размеров окна
moved — событие перемещения картинки

Параметры:

image — ссылка на изображение
matrix — матрица трансформации для CSS-свойства transform
transform — объект, описывающий трансформации

В целях лучшего контроля над положением изображения, img имеет абсолютное позиционирование, а свойству transform-origin, опорной точке трансформации, задано начальное значение “0 0”, что соответствует началу координат в верхнем левом углу исходного (до трансформации!) изображения.

Основная проблема, с которой я столкнулся — нужно следить, чтобы точка transform-origin всегда была в центре области редактирования, иначе, при трансформациях, выделенная часть изображения будет смещаться. Эту задачу помогает решить использование матрицы трансформации.

Компонент Edit

Свойства компонента Edit:

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 };
        }
    },
    ...
}

Значения imageRect и areaRect нам передает компонент Preview, вызывая методы imageLoaded и areaResized, соответственно, объекты имеют структуру:

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

Значения center можно было бы каждый раз вычислять, но проще их записать один раз.

Вычисляемое свойство matrix — это те самые коэффициенты матрицы трансформации.

Первая задача, которую нужно решить — это отцентровать в области кропа изображение с произвольным соотношением сторон, при этом, изображение должно иметь возможность поместиться полностью, незаполненные области (только) сверху и снизу, или (только) слева и справа — допустимы. При любых трансформациях это условие должно сохраняться.

Во-первых, ограничим значения для зуммирования, для этого будем проверять соотношения сторон, учитывая ориентацию изображения.

Методы компонента:

   _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;
},

Теперь перейдем к трансформациям. Для начала, опишем отражения, ибо они не смещают видимую область изображения.

Методы компонента:

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;
},

Трансформации зуммирования, вращения и смещения, уже потребуют корректировки transform-origin.

Методы компонента:

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();
},

Именно на методе _translate лежит ответственность за все тонкости трансформаций. Нужно предствить две системы отсчета. Первая, назвем ее нулевой, начинается в верхнем левом углу изображения, при умножении кординат на матрицу трансформации, мы переходим в другую систему кординат, назовем ее локальной. В таком случае обратный переход, от локальной к нулевой, мы можем осуществить, найдя обратную матрицу преобразования.

Выходит, нам нужны две функции.

Первая — для перехода с нулевой в локальную систему, такие же преобразования выполняет браузер, когда мы указываем css-свойство transform.

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

Вторая — для нахождения оригинальных координат изображения, имея уже трансформированные кординаты.

Удобнее всего записать эти функции методами отдельного класса.

Класс Transform:

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};
    }
}

Метод _translate с подробными комментариями:

_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);
    }

},

Выравнивание создает эффект «прилипания» изображения к краям области кропа, не допуская пустых полей.

Компонент Preview

Основная задача этого компонента — отобразить картинку, применить трансформации и реагировать на перемещение зажатой над изображением, кнопки мыши. Вычисляя смещение, мы обновляем параметры transform.x и transform.y, при завершении движения — триггерим событие moved, сообщая компоненту Edit о том, что нужно заново просчитать положение центра трансформации и скорректировать transform.x и transform.y.

Шаблон компонента Preview:

<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 }">

Функционал редактора аккуратно отделен от основного проекта и лежит здесь.

Надеюсь, для Вас данный материал будет полезен. Спасибо!

Автор: ihormihal

Источник


* - обязательные к заполнению поля