在低代码平台,用户可以选中画布中的组件,然后通过鼠标直接
改变组件的大小、位置和旋转角度。即用可视化的方式实现组件的几何变换
。文章通过手写一个组件几何变换功能来加深理解其背后的原理。
布局
画布中的组件采用绝对定位:即画布是 position: relative
,组件是 position: absolute
。组件的定位信息left/top就是组件在画布中的坐标(x,y)。
实现原理
把几何变换的过程封装成一个包裹组件geometricTransformer,具体的组件以插槽<solt>
形式传递给包裹组件。
-
点击组件使处于选中状态:出现8个小圆点和旋转图标。
-
8个小圆点从不同方向改变组件的width/height,当鼠标
mousedown
小圆点并开始mousemove
时,计算鼠标
在组件高/宽所在方向的位移,更新组件大小和位置。 -
当鼠标
mousedown
旋转图标并开始mousemove
时,计算鼠标
和组件中心点
的连线的旋转角度,更新组件旋转角度。 -
当鼠标
mousedown
组件并开始mousemove
时,计算鼠标的位移,更新组件位置。
代码实现
鼠标事件
下面是geometricTransformer组件的template,并以插槽<slot>
接收具体组件。主要通过监听mousedown
事件来获得输入信息,分别是:旋转图标的mousedown用来计算旋转角度,圆点的mousedown事件用来计算width/height变化,geometricTransformer组件本身的mousedown用来计算位移。
<template>
<div
:class="{ active: isActive }"
@mousedown="onMouseDownComponent"
>
<span
v-show="isActiveAndUnlock"
class="iconfont icon-xiangyouxuanzhuan"
@mousedown="onMouseDownRotate"
></span>
<div
v-for="point in resizePoint.points"
v-show="isActiveAndUnlock"
:key="point.id"
class="resize-point"
:style="point.style"
@mousedown="handleMouseDownOnPoint(point, $event)"
></div>
<slot></slot>
</div>
</template>
几何变换计算过程
下面主要是三个mousedown事件处理函数:
- onMouseDownComponent:通过鼠标移动的位移,计算组件的位置变化。
- onMouseDownRotate:计算鼠标和组件中心点的连线的旋转角度,更新组件旋转角度。
- handleMouseDownOnPoint:计算鼠标在组件高/宽所在方向的位移,更新组件大小和位置。
<script>
import { mapState } from 'vuex';
import ResizePoint from '@/utils/resizePoint';
export default {
props: {
element: {
require: true,
type: Object,
default: () => {},
},
index: {
require: true,
type: [Number, String],
default: 0,
},
},
data() {
return {
resizePoint: {},
};
},
computed: {
...mapState(['curComponent', 'editor']),
isActive() {
return this.element.id == this.curComponent?.id;
},
isActiveAndUnlock() {
return this.isActive && !this.element.isLock;
},
},
watch: {
element: {
handler(element) {
// 当组件的大小和旋转角度变化时,更新圆点的位置和光标
this.resizePoint.computeStyle(element.style);
},
deep: true,
},
},
mounted() {
this.resizePoint = new ResizePoint(this.element.style);
},
methods: {
// 平移:计算鼠标的位移,更新组件位置
onMouseDownComponent(e) {
e.stopPropagation();
this.$store.commit('setCurComponent', {
component: this.element,
index: this.index,
});
const componentStyle = {
...this.element.style,
};
// 组件起始位置
const startTop = componentStyle.top;
const startLeft = componentStyle.left;
// 鼠标起始位置
const startY = e.clientY;
const startX = e.clientX;
let isMove = false;
const move = moveEvent => {
isMove = true;
// 鼠标当前位置
const currentX = moveEvent.clientX;
const currentY = moveEvent.clientY;
// 移动后的组件位置
componentStyle.top = startTop + (currentY - startY);
componentStyle.left = startLeft + (currentX - startX);
this.$store.commit('setCurrentComponentStyle', componentStyle);
};
const up = () => {
hasMove && this.$store.commit('recordSnapshot');
// 触发元素停止移动事件,用于隐藏标线
eventBus.$emit('unmove');
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
},
// 旋转: 计算鼠标和组件中心点的连线的旋转角度,更新组件旋转角度。
onMouseDownRotate(e) {
e.preventDefault();
e.stopPropagation();
const componentStyle = {
...this.element.style,
};
// 鼠标起始位置
const startY = e.clientY;
const startX = e.clientX;
// 组件初始角度
const startRotate = pos.rotate;
// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 旋转前的角度
const rotateDegreeBefore =
Math.atan2(startY - centerY, startX - centerX) /
(Math.PI / 180);
let isMove = false;
const move = moveEvent => {
isMove = true;
// 鼠标当前位置
const currentX = moveEvent.clientX;
const currentY = moveEvent.clientY;
// 旋转后的角度
const rotateDegreeAfter =
Math.atan2(currentY - centerY, currentX - centerX) /
(Math.PI / 180);
// 获取旋转的角度值
componentStyle.rotate =
startRotate +
((rotateDegreeAfter - rotateDegreeBefore + 360) % 360);
// 修改当前组件样式
this.$store.commit('setCurrentComponentStyle', componentStyle);
};
const up = () => {
isMove && this.$store.commit('recordSnapshot');
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
},
// 处理缩放:计算鼠标在组件高/宽所在方向的位移,更新组件大小和位置。
handleMouseDownOnPoint(point, e) {
e.stopPropagation();
e.preventDefault();
const editorDOMRect = this.editor.getBoundingClientRect();
const pointDOMRect = e.target.getBoundingClientRect();
// 当前点击圆点在画布坐标系(左上角为原点)中的坐标
const pointCoordinate = {
x: Math.round(
pointDOMRect.left -
editorDOMRect.left +
e.target.offsetWidth / 2
),
y: Math.round(
pointDOMRect.top -
editorDOMRect.top +
e.target.offsetHeight / 2
),
};
let isSave = false;
const isKeepProportion = this.isKeepProportion();
const componentStyle = { ...this.element.style };
const move = moveEvent => {
isSave = true;
const mouseCoordinate = {
x: moveEvent.clientX - editorDOMRect.left,
y: moveEvent.clientY - editorDOMRect.top,
};
// 调用每个point对象的方法,根据组件样式、鼠标当前位置、圆点位置信息计算组件位移信息。
let newComponentStyle = point.calculateComponentPositonAndSize(
componentStyle,
mouseCoordinate,
isKeepProportion,
pointCoordinate
);
this.$store.commit('setCurrentComponentStyle', newComponentStyle);
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
isSave && this.$store.commit('recordSnapshot');
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
},
},
};
</script>
每个圆点计算组件大小和位置信息的过程是不同的,这里封装成每个point对象的方法。下面以top圆点和rightTop圆点为例:
- 鼠标拖动top圆点时,只改变组件的height,鼠标的x坐标可以是任意值,这里为了方便计算就取落在组件中心点和top圆点的延长线上的鼠标坐标。首先,计算top圆点关于组件中心点的
对称点坐标
;计算鼠标坐标在组件旋转之前的坐标,x坐标取top圆点的x坐标,再将得到的坐标换算成旋转之后的坐标,得到了在组件中心点和top圆点的延长线上的鼠标坐标A
。根据鼠标坐标A和对称点坐标计算出新的组件中心点坐标
,根据勾股定理,可以计算出两点之间的距离,即新的组件高度
。根据新的组件中心点坐标
、新的组件高度
和组件宽度
就可以获得新的组件大小和位置信息。 - 鼠标拖动rightTop圆点时,可以改变组件的height和width。首先,计算rightTop圆点关于组件中心点的
对称点坐标
。然后,根据鼠标坐标
和对称点坐标
计算新的组件中心点坐标
。根据鼠标坐标(新的组件右上角坐标)
、对称点坐标(新的组件左下角坐标)
和新的组件中心点坐标
就可以获得新的组件大小和位置信息。
import {
calculateCenterPoint
, calculateRotatedPoint
, calculateDistance
, calculateComponentCenter
, calculateSymmetricPoint
} from '@/utils/translate';
/**
* 几何变换组件的8个圆点:根据组件样式、鼠标当前位置、圆点位置信息计算组件位移信息。
*/
export default class ResizePoint {
/**
* @param width 8个圆点所在矩形(组件)的宽度
* @param height 8个圆点所在矩形(组件)的高度
* @param rotate 8个圆点所在矩形(组件)的旋转角度
*/
constructor({ width, height, rotate }) {
this.points = [new TopPoint()
, new RightTopPoint()
, new RightPoint()
, new RightBottomPoint()
, new BottomPoint()
, new LeftBottomPoint()
, new LeftPoint()
, new IeftTopPoint()
]
this.computeStyle({ width, height, rotate });
}
// 根据组件的大小和旋转角度,计算圆点的位置和光标
computeStyle({ width, height, rotate }) {
this.points.forEach(point => {
point.computeStyle(width, height, rotate);
});
}
}
// 一共有8个点,分成对角点和每条边的中心点。
// 这里列举top点和rightTop点的计算,其它点也是类似的计算。
class TopPoint {
constructor() {
this.id = 't'
this.style = {
marginLeft: '-4px',
marginTop: '-4px',
left: ``,
top: ``,
cursor: '',
}
}
computeStyle(width, height, rotate) {
this.style.left = width / 2 + 'px';
this.style.top = 0;
this.style.cursor = angle2Cursor('n-resize', Number(rotate));
}
/**
* @param {object} componentStyle 组件样式
* @param {object} mouseCoordinate 鼠标当前坐标(画布坐标系)
* @param {object} isKeepProportion 是否保持高度/宽度的比例
* @param {object} point 圆点坐标(画布坐标系)
*/
calculateComponentPositonAndSize(componentStyle, mouseCoordinate, isKeepProportion, point) {
const componentCenter = calculateComponentCenter(componentStyle);
const symmetricPoint = calculateSymmetricPoint(point, componentCenter);
//鼠标拖动top圆点时,只改变组件的height,鼠标的x坐标可以是任意值,这里为了方便计算就取落在组件中心点和top圆点的延长线上的鼠标坐标。
// 先计算旋转前的鼠标坐标。旋转中心用 point, componentCenter, symmetricPoint 都可以,只要他们在一条直线上就行
const beforeRotatedMouseCoordinate = calculateRotatedPoint(mouseCoordinate, point, -componentStyle.rotate)
// 算出旋转前的鼠标的y坐标,top圆点的x坐标,重新计算它们旋转后对应的坐标
const rotatedTopPoint = calculateRotatedPoint({
x: point.x,
y: beforeRotatedMouseCoordinate.y,
}, point, componentStyle.rotate)
const newComponentHeight = calculateDistance(rotatedTopPoint, symmetricPoint);
const newComponentCenter = calculateCenterPoint(rotatedTopPoint, symmetricPoint);
let newComponentWidth = componentStyle.width;
if (isKeepProportion) {
// 组件宽高比
const componentProportion = componentStyle.width / componentStyle.height;
newComponentWidth = newComponentHeight * componentProportion
}
let newComponentStyle = { ...componentStyle };
newComponentStyle.width = Math.round(newComponentWidth);
newComponentStyle.height = Math.round(newComponentHeight);
newComponentStyle.top = Math.round(newComponentCenter.y - (newComponentHeight / 2));
newComponentStyle.left = Math.round(newComponentCenter.x - (newComponentWidth / 2));
return newComponentStyle;
}
}
class RightTopPoint {
constructor() {
this.id = 'rt'
this.style = {
marginLeft: '-4px',
marginTop: '-4px',
left: ``,
top: ``,
cursor: '',
}
}
computeStyle(width, height, rotate) {
this.style.left = width + 'px';
this.style.top = 0;
this.style.cursor = angle2Cursor('ne-resize', Number(rotate));
}
/**
* @param {object} componentStyle 组件样式
* @param {object} mouseCoordinate 鼠标当前坐标(画布坐标系)
* @param {object} isKeepProportion 是否保持高度/宽度的比例
* @param {object} point 圆点坐标(画布坐标系)
*/
calculateComponentPositonAndSize(componentStyle, mouseCoordinate, isKeepProportion, point) {
const componentCenter = calculateComponentCenter(componentStyle);
const symmetricPoint = calculateSymmetricPoint(point, componentCenter);
const newComponentCenter = calculateCenterPoint(mouseCoordinate, symmetricPoint)
const newRightTopPoint = calculateRotatedPoint(mouseCoordinate, newComponentCenter, -componentStyle.rotate)
const newBottomLeftPoint = calculateRotatedPoint(symmetricPoint, newComponentCenter, -componentStyle.rotate)
let newComponentWidth = newRightTopPoint.x - newBottomLeftPoint.x
let newComponentHeight = newBottomLeftPoint.y - newRightTopPoint.y
if (isKeepProportion) {
// 组件宽高比
const componentProportion = componentStyle.width / componentStyle.height;
if (newComponentWidth / newComponentHeight > componentProportion) {
newRightTopPoint.x -= Math.abs(newComponentWidth - newComponentHeight * componentProportion)
newComponentWidth = newComponentHeight * componentProportion
} else {
newRightTopPoint.y += Math.abs(newComponentHeight - newComponentWidth / componentProportion)
newComponentHeight = newComponentWidth / componentProportion
}
const rotatedTopRightPoint = calculateRotatedPoint(newRightTopPoint, newComponentCenter, componentStyle.rotate)
newComponentCenter = calculateCenterPoint(rotatedTopRightPoint, symmetricPoint)
newRightTopPoint = calculateRotatedPoint(rotatedTopRightPoint, newComponentCenter, -componentStyle.rotate)
newBottomLeftPoint = calculateRotatedPoint(symmetricPoint, newComponentCenter, -componentStyle.rotate)
newComponentWidth = newRightTopPoint.x - newBottomLeftPoint.x
newComponentHeight = newBottomLeftPoint.y - newRightTopPoint.y
}
let newComponentStyle = { ...componentStyle };
if (newComponentWidth > 0 && newComponentHeight > 0) {
newComponentStyle.width = Math.round(newComponentWidth)
newComponentStyle.height = Math.round(newComponentHeight)
newComponentStyle.left = Math.round(newBottomLeftPoint.x)
newComponentStyle.top = Math.round(newRightTopPoint.y)
}
return newComponentStyle;
}
}
let cursorList = ['n-resize', 'ne-resize', 'e-resize', 'se-resize', 's-resize', 'sw-resize', 'w-resize', 'nw-resize'];
/**
* 计算每个圆点的光标。
* 一共有8个方向的光标,相邻两个方向的光标相差45度,将光标按顺时针方向排列(cursorList)。
* 每个圆点都会设置一个初始光标,然后根据旋转角度计算出漂移量,在cursorList中找到新的光标。
* 例如:right圆点的初始光标为e-resize,旋转90度后,漂移量为90/45=2,在cursorList中按顺序找到新的光标为s-resize。
*/
function angle2Cursor(defaultCursor, rotateAngle) {
let defaultIndex = cursorList.indexOf(defaultCursor);
let offset = Math.trunc(rotateAngle / 45);
let currentIndex = Math.abs((defaultIndex + offset) % cursorList.length);
return cursorList[currentIndex];
}