Хабрахабр

[Из песочницы] Простой редактор изображений на VueJS

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

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

image

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

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

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

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

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

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

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

@mousedown=«onMoveStart»
@touchstart=«onMoveStart»
mouseup=«onMoveEnd»
@touchend=«onMoveEnd»
@mousemove=«onMove»
@touchmove=«onMove»>
v-if=«image»
ref=«image»
load=«imageLoaded»
:src=«image»
:style="{ 'transform': transformStyle, 'transform-origin': transformOrigin }">

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»