编程 从零实现可视化拖拽编辑器:拖拽、吸附、撤销重做的实现细节

2026-09-17 00:04:34

从零实现可视化拖拽编辑器:拖拽、吸附、撤销重做的实现细节

项目信息

  • 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/2height/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-pickertextAlignel-select,数值用 type=numberel-input,另外内容用 textarea 绑定 propValue。全部用 v-model 双向绑定。

9. 预览、保存代码

预览和编辑的渲染原理一样,区别只是不需要编辑功能:把 Shape 组件去掉,外面套一个普通 DIV(为了绑事件)。

保存代码只需要保存画布数据 componentData,可以存到服务器,也可以存在本地(DEMO 用的是 localStorage)。

10. 绑定事件

每个组件有一个 events 对象存绑定的事件。DEMO 里只定义了 alertredirect 两个:

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 对象。预览时给每个组件套一层 DIVclickhandleClick() 循环触发 events 里的事件。

11. 绑定动画

原理和事件一样:v-for 渲染动画列表,点击把动画加入组件的 animations 数组,执行时遍历执行。动画用 animate.css,预先定义好动画数据(进入、强调、退出三大类,如 fadeInzoomInbounce 等)。

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-h5luban-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 自己出码,一边是直接生成/驱动源码,两条路线在可维护性、二次编辑、和业务代码的衔接上取舍完全不同。

推荐文章

程序员茄子在线接单