5. 放大缩小
细心的网友可能会发现,点击画布上的组件时,组件上会出现 8 个小圆点。这 8 个小圆点就是用来放大缩小用的。实现原理如下:
1. 在每个组件外面包一层 Shape
组件,Shape
组件里包含 8 个小圆点和一个 <slot>
插槽,用于放置组件。
<!--页面组件列表展示--> <Shape v-for="(item, index) in componentData" :defaultStyle="item.style" :style="getShapeStyle(item.style, index)" :key="item.id" :active="item === curComponent" :element="item" :zIndex="index" > <component class="component" :is="item.component" :style="getComponentStyle(item.style)" :propValue="item.propValue" /> </Shape>
Shape
组件内部结构:
<template> <div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown" @contextmenu="handleContextMenu"> <div class="shape-point" v-for="(item, index) in (active? pointList : [])" @mousedown="handleMouseDownOnPoint(item)" :key="index" :style="getPointStyle(item)"> </div> <slot></slot> </div> </template>
2. 点击组件时,将 8 个小圆点显示出来。
起作用的是这行代码 :active="item === curComponent"
。
3. 计算每个小圆点的位置。
先来看一下计算小圆点位置的代码:
const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'] getPointStyle(point) { const { width, height } = this.defaultStyle const hasT = /t/.test(point) const hasB = /b/.test(point) const hasL = /l/.test(point) const hasR = /r/.test(point) let newLeft = 0 let newTop = 0 // 四个角的点 if (point.length === 2) { newLeft = hasL? 0 : width newTop = hasT? 0 : height } else { // 上下两点的点,宽度居中 if (hasT || hasB) { newLeft = width / 2 newTop = hasT? 0 : height } // 左右两边的点,高度居中 if (hasL || hasR) { newLeft = hasL? 0 : width newTop = Math.floor(height / 2) } } const style = { marginLeft: hasR? '-4px' : '-3px', marginTop: '-3px', left: `${newLeft}px`, top: `${newTop}px`, cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize', } return style }
计算小圆点的位置需要获取一些信息:
- 组件的高度
height
、宽度width
注意,小圆点也是绝对定位的,相对于 Shape
组件。所以有四个小圆点的位置很好确定:
- 左上角的小圆点,坐标
left: 0, top: 0
- 右上角的小圆点,坐标
left: width, top: 0
- 左下角的小圆点,坐标
left: 0, top: height
- 右下角的小圆点,坐标
left: width, top: height
另外的四个小圆点需要通过计算间接算出来。例如左边中间的小圆点,计算公式为 left: 0, top: height / 2
,其他小圆点同理。
4. 点击小圆点时,可以进行放大缩小操作。
handleMouseDownOnPoint(point) { const downEvent = window.event downEvent.stopPropagation() downEvent.preventDefault() const pos = { ...this.defaultStyle } const height = Number(pos.height) const width = Number(pos.width) const top = Number(pos.top) const left = Number(pos.left) const startX = downEvent.clientX const startY = downEvent.clientY // 是否需要保存快照 let needSave = false const move = (moveEvent) => { needSave = true const currX = moveEvent.clientX const currY = moveEvent.clientY const disY = currY - startY const disX = currX - startX const hasT = /t/.test(point) const hasB = /b/.test(point) const hasL = /l/.test(point) const hasR = /r/.test(point) const newHeight = height + (hasT? -disY : hasB? disY : 0) const newWidth = width + (hasL? -disX : hasR? disX : 0) pos.height = newHeight > 0? newHeight : 0 pos.width = newWidth > 0? newWidth : 0 pos.left = left + (hasL? disX : 0) pos.top = top + (hasT? disY : 0) this.$store.commit('setShapeStyle', pos) } const up = () => { document.removeEventListener('mousemove', move) document.removeEventListener('mouseup', up) needSave && this.$store.commit('recordSnapshot') } document.addEventListener('mousemove', move) document.addEventListener('mouseup', up) }
它的原理是这样的:
- 点击小圆点时,记录点击的坐标 xy。
- 假设我们现在向下拖动,那么 y 坐标就会增大。
- 用新的 y 坐标减去原来的 y 坐标,就可以知道在纵轴方向的移动距离是多少。
- 最后再将移动距离加上原来组件的高度,就可以得出新的组件高度。
- 如果是正数,说明是往下拉,组件的高度在增加。如果是负数,说明是往上拉,组件的高度在减少。
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 < state.snapshotData.length - 1) { state.snapshotIndex++ store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex])) } }, setComponentData(state, componentData = []) { Vue.set(state, 'componentData', componentData) }, recordSnapshot(state) { // 添加新的快照 state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData) // 在 undo 过程中,添加新的快照时,要将它后面的快照清理掉 if (state.snapshotIndex < state.snapshotData.length - 1) { state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1) } },
用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 push()
操作,将当前的编辑器数据推入 snapshotData
数组,并增加快照索引 snapshotIndex
。目前以下几个动作会触发保存快照操作:
- 新增组件
- 删除组件
- 改变图层层级
- 拖动组件结束时
...
撤销
假设现在 snapshotData
保存了 4 个快照。即 [a, b, c, d]
,对应的快照索引为 3。如果这时进行了撤销操作,我们需要将快照索引减 1,然后将对应的快照数据赋值给画布。
例如当前画布数据是 d,进行撤销后,索引 -1,现在画布的数据是 c。
重做
明白了撤销,那重做就很好理解了,就是将快照索引加 1,然后将对应的快照数据赋值给画布。
不过还有一点要注意,就是在撤销操作中进行了新的操作,要怎么办呢?有两种解决方案:
- 新操作替换当前快照索引后面所有的数据。还是用刚才的数据
[a, b, c, d]
举例,假设现在进行了两次撤销操作,快照索引变为 1,对应的快照数据为 b,如果这时进行了新的操作,对应的快照数据为 e。那 e 会把 cd 顶掉,现在的快照数据为[a, b, e]
。 - 不顶掉数据,在原来的快照中新增一条记录。用刚才的例子举例,e 不会把 cd 顶掉,而是在 cd 之前插入,即快照数据变为
[a, b, e, c, d]
。
我采用的是第一种方案。
7. 吸附
什么是吸附?就是在拖拽组件时,如果它和另一个组件的距离比较接近,就会自动吸附在一起。
吸附的代码大概在 300 行左右,建议自己打开源码文件看(文件路径:src\components\Editor\MarkLine.vue
)。这里不贴代码了,主要说说原理是怎么实现的。
标线
在页面上创建 6 条线,分别是三横三竖。这 6 条线的作用是对齐,它们什么时候会出现呢?
- 上下方向的两个组件左边、中间、右边对齐时会出现竖线
- 左右方向的两个组件上边、中间、下边对齐时会出现横线
具体的计算公式主要是根据每个组件的 xy 坐标和宽度高度进行计算的。例如要判断 ab 两个组件的左边是否对齐,则要知道它们每个组件的 x 坐标;如果要知道它们右边是否对齐,除了要知道 x 坐标,还要知道它们各自的宽度。
// 左对齐的条件 a.x == b.x // 右对齐的条件 a.x + a.width == b.x + b.width
在对齐的时候,显示标线。
另外还要判断 ab 两个组件是否“足够”近。如果足够近,就吸附在一起。是否足够近要靠一个变量来判断:
diff: 3, // 相距 dff 像素将自动吸附
小于等于 diff
像素则自动吸附。
吸附
吸附效果是怎么实现的呢?
假设现在有 ab 组件,a 组件坐标 xy 都是 0,宽高都是 100。现在假设 a 组件不动,我们正在拖拽 b 组件。当把 b 组件拖到坐标为 x: 0, y: 103
时,由于 103 - 100 <= 3(diff)
,所以可以判定它们已经接近得足够近。这时需要手动将 b 组件的 y 坐标值设为 100,这样就将 ab 组件吸附在一起了。
优化
在拖拽时如果 6 条标线都显示出来会不太美观。所以我们可以做一下优化,在纵横方向上最多只同时显示一条线。实现原理如下:
- a 组件在左边不动,我们拖着 b 组件往 a 组件靠近。
- 这时它们最先对齐的是 a 的右边和 b 的左边,所以只需要一条线就够了。
- 如果 ab 组件已经靠近,并且 b 组件继续往左边移动,这时就要判断它们俩的中间是否对齐。
- b 组件继续拖动,这时需要判断 a 组件的左边和 b 组件的右边是否对齐,也是只需要一条线。
可以发现,关键的地方是我们要知道两个组件的方向。即 ab 两个组件靠近,我们要知道到底 b 是在 a 的左边还是右边。
这一点可以通过鼠标移动事件来判断,之前在讲解拖拽的时候说过,mousedown
事件触发时会记录起点坐标。所以每次触发 mousemove
事件时,用当前坐标减去原来的坐标,就可以判断组件方向。例如 x 方向上,如果 b.x - a.x
的差值为正,说明是 b 在 a 右边,否则为左边。
// 触发元素移动事件,用于显示标线、吸附功能 // 后面两个参数代表鼠标移动方向 // currY - startY > 0 true 表示向下移动 false 表示向上移动 // currX - startX > 0 true 表示向右移动 false 表示向左移动 eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)