Недавно мне выпала возможность написать сервис для интернет-магазина, который помогал бы оформить заказ на печать своих фото.
Сервис предполагал наличие «простого» редактора изображений, созданием которого, я бы хотел поделиться. А все потому, что среди обилия всевозможных плагинов я не нашел подходящего функционала, к тому же, нюансы CSS трансформаций, неожиданно стали для меня весьма нетривиальной задачей.
Основные задачи:
- Возможность загрузки изображений с устройства, Google Drive и Instagram.
- Редактирование изображения: перемещение, вращение, отражение по горизонтали и вертикали, зуммирование, автоматическое выравнивание изображения для заполнения области кропа.
Если тема окажется интересной, в следующей публикации я детально опишу интеграцию с Google Drive и Instagram в backend-части приложения, где была использована популярная связка NodeJS+Express.
Для организации frontend-a я выбрал замечательный фреймворк Vue. Просто потому что он меня вдохновляет после тяжелого Angular и надоевшего React. Думаю, нет смысла описывать архитектуру, роуты и прочие компоненты, перейдем сразу к редактору.
Кстати, демку редактора можно потыкать здесь.
Нам понадобится два компонента:
Edit — будет содержать основную логику и элементы управления
Preview — будет отвечать за отображение картинки
<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
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);
}
Вторая — для нахождения оригинальных координат изображения, имея уже трансформированные кординаты.
Удобнее всего записать эти функции методами отдельного класса.
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(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.
@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