woai3c / Front-end-articles

分享我的编程经验和学习心得,订阅请点 watch

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

可视化拖拽组件库一些技术要点原理分析(二)

woai3c opened this issue · comments

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

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

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

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

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

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

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

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 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 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 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)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 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
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

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

    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(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }
    
    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    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}`))
})

参考资料

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

可以从现有项目提取一个最小运行时,只包含自定义组件和预览页面相关的代码。单独打包成一个项目运行。这样就有两个项目,A 项目是用户拖拽的,B 是直接用于渲染的最小运行时。当 A 项目点击发布时,就把当前的组件数据发布到服务器,同时生成对应的 ID。在访问 B 项目时把 ID 带上,后台把组件数据返回,在 B 项目上渲染。

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

可以从现有项目提取一个最小运行时,只包含自定义组件和预览页面相关的代码。单独打包成一个项目运行。这样就有两个项目,A 项目是用户拖拽的,B 是直接用于渲染的最小运行时。当 A 项目点击发布时,就把当前的组件数据发布到服务器,同时生成对应的 ID。在访问 B 项目时把 ID 带上,后台把组件数据返回,在 B 项目上渲染。

你说的这个方式我懂了。
我现在遇到个需求。
在A项目拖拽排布生成预览页面,点击一个按钮,生成一个文件夹或者文件并下载。下载之后得到的这个文件可以放到服务器进行部署,出来的就是之前排好的页面。
所以我会问如何生成.vue或者.html文件,如何打包。
对于这个需求,期待你的建议

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

可以从现有项目提取一个最小运行时,只包含自定义组件和预览页面相关的代码。单独打包成一个项目运行。这样就有两个项目,A 项目是用户拖拽的,B 是直接用于渲染的最小运行时。当 A 项目点击发布时,就把当前的组件数据发布到服务器,同时生成对应的 ID。在访问 B 项目时把 ID 带上,后台把组件数据返回,在 B 项目上渲染。

你说的这个方式我懂了。
我现在遇到个需求。
在A项目拖拽排布生成预览页面,点击一个按钮,生成一个文件夹或者文件并下载。下载之后得到的这个文件可以放到服务器进行部署,出来的就是之前排好的页面。
所以我会问如何生成.vue或者.html文件,如何打包。
对于这个需求,期待你的建议

这个目前我也没有好的思路。

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

可以从现有项目提取一个最小运行时,只包含自定义组件和预览页面相关的代码。单独打包成一个项目运行。这样就有两个项目,A 项目是用户拖拽的,B 是直接用于渲染的最小运行时。当 A 项目点击发布时,就把当前的组件数据发布到服务器,同时生成对应的 ID。在访问 B 项目时把 ID 带上,后台把组件数据返回,在 B 项目上渲染。

你说的这个方式我懂了。
我现在遇到个需求。
在A项目拖拽排布生成预览页面,点击一个按钮,生成一个文件夹或者文件并下载。下载之后得到的这个文件可以放到服务器进行部署,出来的就是之前排好的页面。
所以我会问如何生成.vue或者.html文件,如何打包。
对于这个需求,期待你的建议

这个目前我也没有好的思路。

我现在有个方案,拿出来分享一下。
1、在public文件夹下新建一个文件夹,里面放上所有的静态资源、组件之类的,跟最小运行时有点像。这些文件,会被vue-cli自动复制一份。
2、写一个函数,接收一个json数据,返回一个模板字符串,这个字符串内容是html,会引入public里面的那些静态资源,例如vue.js、normalize.css、组件……就是把东西都引入。然后script里面new一个Vue,把拿到的json数据放进data里面。这里我引入了http-vue-loader.js,这样就可以把.vue拿来用了。
3、使用axios、jszip、file-saver。把html模板字符串用window.URL.createObjectURL转一下,然后和之前放在public里面的文件一起打包给用户。最终用户就可以得到一个zip包,解压之后是index.html和一堆静态文件,这些东西直接拖到服务器,就成了一个静态页面。

您好,怎么能保存或者预览的时候生成透明背景的图片呢

您好,怎么能保存或者预览的时候生成透明背景的图片呢

把网页保存成图片吗?

关于发布和生成.vue文件的详细方案,能细讲一下嘛,谢谢

关于发布和生成.vue文件的详细方案,能细讲一下嘛,谢谢

没有做这个,只做了保存 json 数据。

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

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

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

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

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

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

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

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 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 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 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)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 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
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

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

    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(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }
    
    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    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}`))
})

参考资料

请问导入PSD文件后,如何能在画布上展示出其多个图层,并且能够选中图层进行拖动、修改等,您方便提供一下思路、方法么?

目前没有深入研究这个,可以看看 https://github.com/huangwei9527/quark-h5

如何发布 没看明白 有具体的操作吗

如何发布 没看明白 有具体的操作吗

我是这样做的,发布就是调后端接口把组件数据存到数据ylkd。然后返回一个页面 url,用户再去访问这个 url,这个 url 带上一个 id,打开页面调后端接口,参数是 id,这样拿到对应的组件数据再渲染。

commented

预览我没看太懂诶,是把画布定义好的组件数据传给后台,后台生成id,然后预览时内嵌一个iframe访问后台网址拿到数据去渲染吗?

预览我没看太懂诶,是把画布定义好的组件数据传给后台,后台生成id,然后预览时内嵌一个iframe访问后台网址拿到数据去渲染吗?

预览是前端做的,和后端无关。建议看看代码。

想知道如何实现出码

想知道如何实现

你好,关于发布的部分可以详细说明下吗?
如何生成.vue或者.html文件,如何打包,如何发布之类的
或者在代码里更新下这部分的功能,我最近正好在做可视化布局相关的项目

可以从现有项目提取一个最小运行时,只包含自定义组件和预览页面相关的代码。单独打包成一个项目运行。这样就有两个项目,A 项目是用户拖拽的,B 是直接用于渲染的最小运行时。当 A 项目点击发布时,就把当前的组件数据发布到服务器,同时生成对应的 ID。在访问 B 项目时把 ID 带上,后台把组件数据返回,在 B 项目上渲染。

你说的这个方式我懂了。 我现在遇到个需求。 在A项目拖拽排布生成预览页面,点击一个按钮,生成一个文件夹或者文件并下载。下载之后得到的这个文件可以放到服务器进行部署,出来的就是之前排好的页面。 所以我会问如何生成.vue或者.html文件,如何打包。 对于这个需求,期待你的建议

你可以参考一下阿里的低代码引擎,你这个功能类似于出码,得需要做底层的解析,https://lowcode-engine.cn/docV2/lhggxn

emm 我想知道低码的埋点和监控是怎么做的?有知道的小伙子么,大伙都可以说下

想知道如何实现出码

目前没做这个功能,想做的话,只需要遍历每个组件的数据,根据 type props 生成 HTML 代码,然后看情况生成 js 代码。同时对应的组件代码也需要生成,这个直接复制就行。

emm 我想知道低码的埋点和监控是怎么做的?有知道的小伙子么,大伙都可以说下

生成的页面自动插入监控 sdk。

判断按键是否伴随ctrl键, 可以用KeyboardEvent.ctrlKey属性
: Returns a boolean value that is true if the Ctrl key was active when the key event was generated.

为什么按下鼠标时通过组件的坐标(无论旋转多少度,组件的 top left 属性不变)

为什么按下鼠标时通过组件的坐标(无论旋转多少度,组件的 top left 属性不变)

要是通过 getBoundingClientRect() 计算还是会变的。但是不用这个来计算,仍然用的是之前的 xy 坐标。

为什么按下鼠标时通过组件的坐标(无论旋转多少度,组件的 top left 属性不变)

要是通过 getBoundingClientRect() 计算还是会变的。但是不用这个来计算,仍然用的是之前的 xy 坐标。

是不是只能说明旋转的时候,中心点是不变的,所以可以通过特殊的点去求那个中心点的值

为什么按下鼠标时通过组件的坐标(无论旋转多少度,组件的 top left 属性不变)

要是通过 getBoundingClientRect() 计算还是会变的。但是不用这个来计算,仍然用的是之前的 xy 坐标。

是不是只能说明旋转的时候,中心点是不变的,所以可以通过特殊的点去求那个中心点的值

可以这么理解

有没有可以把预览区导出一个html文件的思路

有没有可以把预览区导出一个html文件的思路

遍历每个组件的 JSON 代码,然后生成对应的组件 HTML 代码。最后再把对应的组件代码文件引进来,或者提前把所有组件代码打成一个 js 文件引起来。