从零实现可视化拖拽编辑器:拖拽、吸附、撤销重做的实现细节
项目信息
- GitHub:
- 在线预览:
下面按模块整理编辑器、自定义组件、拖拽、层级、缩放、撤销重做、吸附、属性面板、预览出码、事件与动画、PSD 导入、手机模式这几个技术点的实现思路。
1. 编辑器
画布要做的事:从左侧组件列表拖出组件放到画布上,画布负责把它渲染出来。
思路很简单:
- 用一个数组
componentData维护编辑器里的全部数据。 - 拖拽到画布时,用
push()把新组件数据塞进componentData。 - 编辑器用
v-for遍历componentData,逐个渲染(也可以用 JSX +render())。
单个组件的数据结构大致是这样:
{
component: 'v-text', // 组件名称,需提前注册到 Vue
label: '文字', // 左侧组件列表中显示的名字
propValue: '文字', // 组件所使用的值
icon: 'el-icon-edit',
animations: [], // 动画列表
events: {}, // 事件列表
style: {
width: 200, height: 33, fontSize: 14, fontWeight: 500,
lineHeight: '', letterSpacing: 0, textAlign: '', color: ''
}
}
遍历时主要靠 is 属性识别真正要渲染哪个组件,{ component: 'v-text' } 会被转成 ``。
2. 自定义组件
原则上可以直接用第三方组件,但建议自己封装一层。每个组件数据都暴露一个 propValue 用来传值:单属性写成 propValue: 'aaa',多属性写成 propValue: { a: 1, b: 'text' }。
示例组件有 Picture(图片)、VButton(按钮)、VText(文本)。VText 在编辑模式下渲染成 textarea,非编辑模式按 \n 拆行渲染成 div。
3. 拖拽
分两段:从组件列表拖到画布,以及在画布内移动。
从组件列表到画布
元素要可拖拽,必须加 draggable 属性。
dragstart:拖拽刚开始时触发,把被拖组件的信息交给画布。handleDragStart里用e.dataTransfer.setData('index', e.target.dataset.index)。drop:拖拽结束时触发,接收组件信息。
列表侧:
...
画布侧:
handleDrop(e) {
e.preventDefault()
e.stopPropagation()
const component = deepCopy(componentList[e.dataTransfer.getData('index')])
this.$store.commit('addComponent', component)
}
组件在画布内移动
画布 position: relative,每个组件 position: absolute。
mousedown:记录组件当前位置(xy 坐标,实际对应 css 的left/top)。mousemove:用当前坐标减起始坐标得到移动距离,改组件位置。mouseup:结束移动。
handleMouseDown(e) {
e.stopPropagation()
this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex })
const pos = { ...this.defaultStyle }
const startY = e.clientY
const startX = e.clientX
const startTop = Number(pos.top) // 直接改属性类型会变字符串,要转数值
const startLeft = Number(pos.left)
const move = (moveEvent) => {
const currX = moveEvent.clientX
const currY = moveEvent.clientY
pos.top = currY - startY + startTop
pos.left = currX - startX + startLeft
this.$store.commit('setShapeStyle', pos)
}
const up = () => {
document.removeEventListener('mousemove', move)
document.removeEventListener('mouseup', up)
}
document.addEventListener('mousemove', move)
document.addEventListener('mouseup', up)
}
4. 删除组件、调整图层层级
图层层级直接跟数据顺序绑定:拖到画布上的组件有先后,按数组顺序分配层级。画布上依次新增 a、b、c、d、e,数据顺序就是 [a, b, c, d, e],z-index 对应 0 1 2 3 4,后来居上。
所以「改变层级」本质上就是改变组件数据在 componentData 数组里的顺序:b 上移一位,就和 c 换位置;a 置顶,就和最后一个换位置。
删除组件就是 componentData.splice(index, 1)。
5. 放大缩小
点击组件时,组件四周出现 8 个小圆点用于缩放。
- 每个组件外包一层
Shape组件,Shape内含 8 个小圆点和一个 `` 用来放真正的组件。 - 点击组件后显示小圆点,靠
:active="item === curComponent"控制。 - 小圆点位置:
pointList = ['t','r','b','l','lt','rt','lb','rb'],四个角直接取0/width/height,边中点取width/2或height/2。小圆点同样是绝对定位。 - 拖小圆点缩放:记录点击时的坐标,向下拖 y 增大,新 y 减原 y 得到移动距离,加上原高度就是新高度;正数拉大、负数缩小。高度需要兜底:
pos.height = newHeight > 0 ? newHeight : 0,并在mouseup时判断needSave && commit('recordSnapshot')。
6. 撤消、重做
用快照数组加索引实现:
snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
undo(state) {
if (state.snapshotIndex >= 0) {
state.snapshotIndex--
store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
}
}
redo(state) {
if (state.snapshotIndex 0, currX - startX > 0)
吸附这部分代码大约 300 行,文件路径 src\components\Editor\MarkLine.vue。
8. 组件属性设置
每个组件都有通用属性和独有属性,需要一个地方显示并修改它们。做法是定义 AttrList 组件:遍历组件的 style 对象,按属性类型选不同的控件来渲染——颜色用 el-color-picker,textAlign 用 el-select,数值用 type=number 的 el-input,另外内容用 textarea 绑定 propValue。全部用 v-model 双向绑定。
9. 预览、保存代码
预览和编辑的渲染原理一样,区别只是不需要编辑功能:把 Shape 组件去掉,外面套一个普通 DIV(为了绑事件)。
保存代码只需要保存画布数据 componentData,可以存到服务器,也可以存在本地(DEMO 用的是 localStorage)。
10. 绑定事件
每个组件有一个 events 对象存绑定的事件。DEMO 里只定义了 alert 和 redirect 两个:
const events = {
redirect(url) { if (url) window.location.href = url },
alert(msg) { if (msg) alert(msg) },
}
const eventList = [
{ key: 'redirect', label: '跳转事件', event: events.redirect, param: '' },
{ key: 'alert', label: 'alert 事件', event: events.alert, param: '' },
]
编辑时不触发,预览时才触发。用 v-for 渲染事件列表,选中某个事件就把它添加进组件的 events 对象。预览时给每个组件套一层 DIV 绑 click:handleClick() 循环触发 events 里的事件。
11. 绑定动画
原理和事件一样:v-for 渲染动画列表,点击把动画加入组件的 animations 数组,执行时遍历执行。动画用 animate.css,预先定义好动画数据(进入、强调、退出三大类,如 fadeIn、zoomIn、bounce 等)。
export default async function runAnimation($el, animations = []) {
const play = (animation) => new Promise(resolve => {
$el.classList.add(animation.value, 'animated')
const removeAnimation = () => {
$el.removeEventListener('animationend', removeAnimation)
$el.removeEventListener('animationcancel', removeAnimation)
$el.classList.remove(animation.value, 'animated')
resolve()
}
$el.addEventListener('animationend', removeAnimation)
$el.addEventListener('animationcancel', removeAnimation)
})
for (let i = 0, len = animations.length; i < len; i++) {
await play(animations[i])
}
}
需要同时监听 animationend(动画结束)和 animationcancel(意外终止),配合 Promise 让多个动画依次执行。
12. 导入 PSD
用 psd.js 解析 PSD 文件,解析出来的数据(layer/group 的 left/right/top/bottom/width/height/text/font 等)和 css 很像,写一个转换函数转成组件数据即可。quark-h5 和 luban-h5 都是这么实现 PSD 转换的。DEMO 里未实现这个功能。
13. 手机模式
画布可以调整大小,用 iPhone6 的分辨率开发手机页面。自定义组件需要换成移动端 UI 组件库,或自研移动端专用组件,否则会有样式偏差。
多个组件的组合和拆分
拆开看是 4 个技术点:选中区域、组合后的移动旋转、组合后的放大缩小、拆分后子组件样式的恢复。
- 新建一个
Group组合组件,把区域内的组件变成它的子组件。创建时记录每个子组件在 Group 内的相对位移和相对大小(百分比),而不是绝对值。用绝对值的话,移动 Group 时要连带计算每个子组件,子组件一多计算量就上来,容易卡顿;相对值只在创建时算一次,之后移动、旋转 Group 只用管自己。 - 组合后的放大缩小:只要保持 Group 的宽高比例,子组件就能跟着按比例缩放。所以问题转化成「怎么让 Group 缩放时保持宽高比」。只有 Group 内存在带旋转的子组件时才需要保比例,创建 Group 时可以判断一下子组件有没有旋转角度。
- 拆分后子组件样式恢复:遍历 Group 的子组件,用
getBoundingClientRect()拿到相对视口的left/top/width/height,算出中心点坐标;把子组件的百分比宽高乘上 Group 的宽高得到绝对值,再用中心点减去子组件尺寸的一半,就得到新的left/top。
锁定组件
给组件加一个 isLock 属性。解锁之前不能移动、放大缩小、旋转、复制、粘贴;选中时不给那 8 个圆点和旋转图标,改为显示锁图标,并把对应按钮置灰。
此外还有文本/矩形组件、快捷键、网格线、编辑器快照的另一种实现方式(把快照存在被操作的组件上,而不是全量保存)、拖拽旋转、复制粘贴剪切、右键菜单、数据交互、发布等。
留下的问题
快照式撤销是全量深拷贝,画布一大,记录频率一高,内存和拷贝耗时都会变成瓶颈——能不能只记录发生变化的组件,或者结合 diff 做增量快照?另一条线是产物形态:一边是把画布数据当 DSL 自己出码,一边是直接生成/驱动源码,两条路线在可维护性、二次编辑、和业务代码的衔接上取舍完全不同。