本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:
- 编辑器
- 自定义组件
- 拖拽
- 删除组件、调整图层层级
- 放大缩小
- 撤消、重做
- 组件属性设置
- 吸附
- 预览、保存代码
- 绑定事件
- 绑定动画
- 导入 PSD
- 手机模式
现在这篇文章会在此基础上再补充 4 个功能点,分别是:
- 拖拽旋转
- 复制粘贴剪切
- 数据交互
- 发布
和上篇文章一样,我已经将新功能的代码更新到了 github:
友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。
14. 拖拽旋转
在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:
- 不支持拖拽旋转。
- 旋转后的放大缩小不正确。
- 旋转后的自动吸附不正确。
- 旋转后八个可伸缩点的光标不正确。
这一小节,我们将逐一解决这四个问题。
拖拽旋转
拖拽旋转需要使用 Math.atan2() 函数。
Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。
简单的说就是以组件中心点为原点 (centerX,centerY)
,用户按下鼠标时的坐标设为 (startX,startY)
,鼠标移动时的坐标设为 (curX,curY)
。旋转角度可以通过 (startX,startY)
和 (curX,curY)
计算得出。
那我们如何得到从点 (startX,startY)
到点 (curX,curY)
之间的旋转角度呢?
第一步,鼠标点击时的坐标设为 (startX,startY)
:
const startY = e.clientY const startX = e.clientX
第二步,算出组件中心点:
// 获取组件中心点位置 const rect = this.$el.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2
第三步,按住鼠标移动时的坐标设为 (curX,curY)
:
const curX = moveEvent.clientX const curY = moveEvent.clientY
第四步,分别算出 (startX,startY)
和 (curX,curY)
对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 Math.atan2()
方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:
// 旋转前的角度 const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180) // 旋转后的角度 const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180) // 获取旋转的角度值, startRotate 为初始角度值 pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore
放大缩小
组件旋转后的放大缩小会有 BUG。
从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。
下面再看一个具体的示例:
从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 y2 - y1
就可以得出拖动距离 s
。这时将组件原来的高度加上 s
就能得出新的高度,同时将组件的 top
、left
属性更新。
现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。
如何解决这个问题呢?我从 github 上的一个项目 snapping-demo 找到了解决方案:将放大缩小和旋转角度关联起来。
解决方案
下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。
现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。
第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 top
left
属性不变)和大小算出组件中心点:
const center = { x: style.left + style.width / 2, y: style.top + style.height / 2, }
第二步,用当前点击坐标和组件中心点算出当前点击坐标的对称点坐标:
// 获取画布位移信息 const editorRectInfo = document.querySelector('#editor').getBoundingClientRect() // 当前点击坐标 const curPoint = { x: e.clientX - editorRectInfo.left, y: e.clientY - editorRectInfo.top, } // 获取对称点的坐标 const symmetricPoint = { x: center.x - (curPoint.x - center.x), y: center.y - (curPoint.y - center.y), }
第三步,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:
const curPositon = { x: moveEvent.clientX - editorRectInfo.left, y: moveEvent.clientY - editorRectInfo.top, } const newCenterPoint = getCenterPoint(curPositon, symmetricPoint) // 求两点之间的中点坐标 function getCenterPoint(p1, p2) { return { x: p1.x + ((p2.x - p1.x) / 2), y: p1.y + ((p2.y - p1.y) / 2), } }
由于组件处于旋转状态,即使你知道了拉伸时移动的 xy
距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。
第四步,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出当前鼠标实时坐标currentPosition
在未旋转时的坐标 newTopLeftPoint
。同时也能根据已知的旋转角度、新的组件中心点、对称点算出组件对称点sPoint
在未旋转时的坐标 newBottomRightPoint
。
对应的计算公式如下:
/** * 计算根据圆心旋转后的点的坐标 * @param {Object} point 旋转前的点坐标 * @param {Object} center 旋转中心 * @param {Number} rotate 旋转的角度 * @return {Object} 旋转后的坐标 * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式 */ export function calculateRotatedPointCoordinate(point, center, rotate) { /** * 旋转公式: * 点a(x, y) * 旋转中心c(x, y) * 旋转后点n(x, y) * 旋转角度θ tan ?? * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy */ return { x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x, y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y, } }
上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个回答中找到了这一公式的推理过程,下面是回答的原文:
通过以上几个计算值,就可以得到组件新的位移值 top
left
以及新的组件大小。对应的完整代码如下:
function calculateLeftTop(style, curPositon, pointInfo) { const { symmetricPoint } = pointInfo const newCenterPoint = getCenterPoint(curPositon, symmetricPoint) const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate) const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate) const newWidth = newBottomRightPoint.x - newTopLeftPoint.x const newHeight = newBottomRightPoint.y - newTopLeftPoint.y if (newWidth > 0 && newHeight > 0) { style.width = Math.round(newWidth) style.height = Math.round(newHeight) style.left = Math.round(newTopLeftPoint.x) style.top = Math.round(newTopLeftPoint.y) } }
现在再来看一下旋转后的放大缩小: