https://habr.com/post/417697/- Обработка изображений
 - VueJS
 - JavaScript
 - CSS
 
Недавно мне выпала возможность написать сервис для интернет-магазина, который помогал бы оформить заказ на печать своих фото.
Сервис предполагал наличие «простого» редактора изображений, созданием которого, я бы хотел поделиться. А все потому, что среди обилия всевозможных плагинов я не нашел подходящего функционала, к тому же, нюансы CSS трансформаций, неожиданно стали для меня весьма нетривиальной задачей.
Основные задачи:
- Возможность загрузки изображений с устройства, Google Drive и Instagram.
 
- Редактирование изображения: перемещение, вращение, отражение по горизонтали и вертикали, зуммирование, автоматическое выравнивание изображения для заполнения области кропа.
 
Если тема окажется интересной, в следующей публикации я детально опишу интеграцию с 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 }">
 
Функционал редактора аккуратно отделен от основного проекта и лежит 
здесь.
Надеюсь, для Вас данный материал будет полезен. Спасибо!