在低代码平台,用户可以选中画布中的组件,然后通过鼠标直接 改变组件的大小、位置和旋转角度。即用可视化的方式实现组件的几何变换 。文章通过手写一个组件几何变换功能来加深理解其背后的原理。
布局
画布中的组件采用绝对定位:即画布是 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,
};
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';
export default class ResizePoint {
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);
});
}
}
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));
}
calculateComponentPositonAndSize(componentStyle, mouseCoordinate, isKeepProportion, point) {
const componentCenter = calculateComponentCenter(componentStyle);
const symmetricPoint = calculateSymmetricPoint(point, componentCenter);
const beforeRotatedMouseCoordinate = calculateRotatedPoint(mouseCoordinate, point, -componentStyle.rotate)
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));
}
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'];
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];
}
|