乐趣区

关于可视化:可视化拖拽组件库一些技术要点原理分析二

本文是对《可视化拖拽组件库一些技术要点原理剖析》[1]的补充。上一篇文章次要解说了以下几个性能点:

1. 编辑器 2. 自定义组件 3. 拖拽 4. 删除组件、调整图层层级 5. 放大放大 6. 吊销、重做 7. 组件属性设置 8. 吸附 9. 预览、保留代码 10. 绑定事件 11. 绑定动画 12. 导入 PSD13. 手机模式

当初这篇文章会在此基础上再补充 4 个性能点,别离是:

•拖拽旋转•复制粘贴剪切•数据交互•公布

和上篇文章一样,我曾经将新性能的代码更新到了 github:

•github 我的项目地址[2]•在线预览[3]

友善揭示:倡议联合源码一起浏览,成果更好(这个 DEMO 应用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 曾经能够反对旋转性能了。然而这个旋转性能还有很多不欠缺的中央:

1. 不反对拖拽旋转。2. 旋转后的放大放大不正确。3. 旋转后的主动吸附不正确。4. 旋转后八个可伸缩点的光标不正确。

这一大节,咱们将逐个解决这四个问题。

拖拽旋转

拖拽旋转须要应用 Math.atan2()[4] 函数。

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 就能得出新的高度,同时将组件的 topleft 属性更新。

当初旋转 180 度,如果这时拖住顶点往下拖动,咱们期待的后果是组件高度减少。但这时计算的形式和原来没旋转时是一样的,所以后果和咱们期待的相同,组件的高度将会变小(如果不了解这个景象,能够想像一下没有旋转的那张图,按住顶点往下拖动)。

如何解决这个问题呢?我从 github 上的一个我的项目 snapping-demo[5] 找到了解决方案:将放大放大和旋转角度关联起来。

解决方案

上面是一个已旋转肯定角度的矩形,假如当初拖动它左上方的点进行拉伸。

当初咱们将一步步剖析如何得出拉伸后的组件的正确大小和位移。

第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 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,
    }
}

下面的公式波及到线性代数中旋转矩阵的常识,对于一个没上过大学的人来说,切实太难了。还好我从知乎上的一个答复 [6] 中找到了这一公式的推理过程,上面是答复的原文:

通过以上几个计算值,就能够失去组件新的位移值 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)
    }
}

当初再来看一下旋转后的放大放大:

主动吸附

主动吸附是依据组件的四个属性 top left width height 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时依然按未旋转时计算。这样就会有一个问题,尽管实际上组件的 top left width height 属性没有变动。但在外观上却产生了变动。上面是两个同样的组件:一个没旋转,一个旋转了 45 度。

能够看进去旋转后按钮的 height 属性和咱们从外观上看到的高度是不一样的,所以在这种状况下就呈现了吸附不正确的 BUG。

解决方案

如何解决这个问题?咱们须要拿组件旋转后的大小及位移来做吸附比照。也就是说不要拿组件理论的属性来比照,而是拿咱们看到的大小和位移做比照。

从上图能够看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度能够通过正弦和余弦算出,右边的红线用正弦计算,左边的红线用余弦计算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一样:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的宽度和高度有了,再依据组件原有的 top left 属性,能够得出组件旋转后新的 top left 属性。上面附上残缺代码:

translateComponentStyle(style) {style = { ...style}
    if (style.rotate != 0) {const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

通过修复后,吸附也能够失常显示了。

光标

光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变动而变动。

解决方案

因为 360 / 8 = 45,所以能够为每一个方向调配 45 度的范畴,每个范畴对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向
initialAngle: { // 每个点对应的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每个范畴的角度对应的光标
    {start: 338, end: 23, cursor: 'nw'},
    {start: 23, end: 68, cursor: 'n'},
    {start: 68, end: 113, cursor: 'ne'},
    {start: 113, end: 158, cursor: 'e'},
    {start: 158, end: 203, cursor: 'se'},
    {start: 203, end: 248, cursor: 's'},
    {start: 248, end: 293, cursor: 'sw'},
    {start: 293, end: 338, cursor: 'w'},
],
cursors: {},

计算形式也很简略:

1. 假如当初组件已旋转了肯定的角度 a。2. 遍历八个方向,用每个方向的初始角度 + a 得出当初的角度 b。3. 遍历 angleToCursor 数组,看看 b 在哪一个范畴中,而后将对应的光标返回。

常常下面三个步骤就能够计算出组件旋转后正确的光标方向。具体的代码如下:

getCursor() {const { angleToCursor, initialAngle, pointList, curComponent} = this
  const rotate = (curComponent.style.rotate + 360) % 360 // 避免角度有正数,所以 + 360
  const result = {}
  let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,升高工夫复杂度
  pointList.forEach(point => {const angle = (initialAngle[point] + rotate) % 360
      const len = angleToCursor.length
      let i = 0
      while (i < len) {lastMatchIndex = (lastMatchIndex + 1) % len
          const angleLimit = angleToCursor[lastMatchIndex]
          if (angle < 23 || angle >= 338) {result[point] = 'nw-resize'
              break
          }

          if (angleLimit.start <= angle && angle < angleLimit.end) {result[point] = angleLimit.cursor + '-resize'
              break
          }
      }
  })

  return result
},

从下面的动图能够看进去,当初八个方向上的光标是能够正确显示的。

15. 复制粘贴剪切

绝对于拖拽旋转性能,复制粘贴就比较简单了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {if (e.keyCode == ctrlKey) {isCtrlDown = true} else if (isCtrlDown && e.keyCode == cKey) {this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {if (e.keyCode == ctrlKey) {isCtrlDown = false}
}

监听用户的按键操作,在按下特定按键时触发对应的操作。

复制操作

在 vuex 中应用 copyData 来示意复制的数据。当用户按下 ctrl + c 时,将以后组件数据深拷贝到 copyData

copy(state) {
    state.copyData = {data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同时须要将以后组件在组件数据中的索引记录起来,在剪切中要用到。

粘贴操作

paste(state, isMouse) {if (!state.copyData) {toast('请抉择组件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data})
    store.commit('recordSnapshot')
    state.copyData = null
},

粘贴时,如果是按键操作 ctrl+v。则将组件的 top left 属性加 10,免得和原来的组件重叠在一起。如果是应用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。

剪切操作

cut({copyData}) {if (copyData) {store.commit('addComponent', { component: copyData.data, index: copyData.index})
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作实质上还是复制,只不过在执行复制后,须要将以后组件删除。为了防止用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就须要将原先剪切的数据进行复原。所以复制数据中记录的索引就起作用了,能够通过索引将原来的数据恢复到原来的地位中。

右键操作

右键操作和按键操作是一样的,一个性能两种触发路径。

<li @click="copy" v-show="curComponent"> 复制 </li>
<li @click="paste"> 粘贴 </li>
<li @click="cut" v-show="curComponent"> 剪切 </li>

cut() {this.$store.commit('cut')
},

copy() {this.$store.commit('copy')
},

paste() {this.$store.commit('paste', true)
},

16. 数据交互

形式一

提前写好一系列 ajax 申请 API,点击组件时按需抉择 API,选好 API 再填参数。例如上面这个组件,就展现了如何应用 ajax 申请向后盾交互:

<template>
    <div>{{propValue.data}}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {this.propValue.api.request(this.propValue.api.params).then(res => {this.propValue.data = res.data})
    },
}
</script>

形式二

形式二适宜纯展现的组件,例如有一个报警组件,能够依据后盾传来的数据显示对应的色彩。在编辑页面的时候,能够通过 ajax 向后盾申请页面可能应用的 websocket 数据:

const data = ['status', 'text'...]

而后再为不同的组件增加上不同的属性。例如有 a 组件,它绑定的属性为 status

// 组件能接管的数据
props: {
    propValue: {type: String,},
    element: {type: Object,},
    wsKey: {
        type: String,
        default: '',
    },
},

在组件中通过 wsKey 获取这个绑定的属性。等页面公布后或者预览时,通过 weboscket 向后盾申请全局数据放在 vuex 上。组件就能够通过 wsKey 拜访数据了。

<template>
    <div>{{wsData[wsKey] }}</div>
</template>

<script>
import {mapState} from 'vuex'

export default {
    props: {
        propValue: {type: String,},
        element: {type: Object,},
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState(['wsData',]),
</script>

和后盾交互的形式有很多种,不仅仅包含下面两种,我在这里仅提供一些思路,以供参考。

17. 公布

页面公布有两种形式:一是将组件数据渲染为一个独自的 HTML 页面;二是从本我的项目中抽取出一个最小运行时 runtime 作为一个独自的我的项目。

这里说一下第二种形式,本我的项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取进去作为一个我的项目独自打包。发布页面时将组件数据以 JSON 的格局传给服务端,同时为每个页面生成一个惟一 ID。

假如当初有三个页面,发布页面生成的 ID 为 a、b、c。拜访页面时只须要把 ID 带上,这样就能够依据 ID 获取每个页面对应的组件数据。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加载

如果自定义组件过大,例如有数十个甚至上百个。这时能够将自定义组件用 import 的形式导入,做到按需加载,缩小首屏渲染工夫:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本公布

自定义组件有可能会有更新的状况。例如原来的组件应用了大半年,当初有性能变更,为了不影响原来的页面。倡议在公布时带上组件的版本号:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 组件有两个版本,在左侧组件列表区应用时就能够带上版本号:

{
  component: 'v-text',
  version: 'v1'
  ...
}

这样导入组件时就能够依据组件版本号进行导入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

参考资料

•Math[7]•通过 Math.atan2 计算角度[8]•为什么矩阵能用来示意角的旋转?[9]•snapping-demo[10]•vue-next-drag[11]

References

[1]《可视化拖拽组件库一些技术要点原理剖析》: _https://juejin.cn/post/690850…
[2] github 我的项目地址: _https://github.com/woai3c/vis…
[3] 在线预览: _https://woai3c.github.io/visu…
[4] Math.atan2(): _https://developer.mozilla.org…\_Objects/Math/atan2_
[5] snapping-demo: _https://github.com/shenhudong…
[6] 答复: _https://www.zhihu.com/questio…
[7] Math: _https://developer.mozilla.org…\_Objects/Math_
[8] 通过 Math.atan2 计算角度: _https://www.jianshu.com/p/981…
[9] 为什么矩阵能用来示意角的旋转?: _https://www.zhihu.com/questio…
[10] snapping-demo: _https://github.com/shenhudong…
[11] vue-next-drag: _https://github.com/lycHub/vue…

退出移动版