拆分后子组件样式的恢复
将多个组件组合在一起只是第一步,第二步是将 Group
组件进行拆分并恢复各个子组件的样式。保证拆分后的子组件在外观上的属性不变。
计算代码如下:
// store decompose({ curComponent, editor }) { const parentStyle = { ...curComponent.style } const components = curComponent.propValue const editorRect = editor.getBoundingClientRect() store.commit('deleteComponent') components.forEach(component => { decomposeComponent(component, editorRect, parentStyle) store.commit('addComponent', { component }) }) } // 将组合中的各个子组件拆分出来,并计算它们新的 style export default function decomposeComponent(component, editorRect, parentStyle) { // 子组件相对于浏览器视口的样式 const componentRect = $(`#component${component.id}`).getBoundingClientRect() // 获取元素的中心点坐标 const center = { x: componentRect.left - editorRect.left + componentRect.width / 2, y: componentRect.top - editorRect.top + componentRect.height / 2, } component.style.rotate = mod360(component.style.rotate + parentStyle.rotate) component.style.width = parseFloat(component.groupStyle.width) / 100 * parentStyle.width component.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height // 计算出元素新的 top left 坐标 component.style.left = center.x - component.style.width / 2 component.style.top = center.y - component.style.height / 2 component.groupStyle = {} }
这段代码的处理逻辑为:
- 遍历
Group
的子组件并恢复它们的样式 - 利用
getBoundingClientRect()
API 获取子组件相对于浏览器视口的left
top
width
height
属性。 - 利用这四个属性计算出子组件的中心点坐标。
- 由于子组件的
width
height
属性是相对于Group
组件的,所以将它们的百分比值和Group
相乘得出具体数值。 - 再用中心点
center(x, y)
减去子组件宽高的一半得出它的left
top
属性。
至此,组合和拆分就讲解完了。
19. 文本组件
文本组件 VText
之前就已经实现过了,但不完美。例如无法对文字进行选中。现在我对它进行了重写,让它支持选中功能。
<template> <div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup"> <!-- tabindex >= 0 使得双击时聚集该元素 --> <div :contenteditable="canEdit" :class="{ canEdit }" @dblclick="setEdit" :tabindex="element.id" @paste="clearStyle" @mousedown="handleMousedown" @blur="handleBlur" ref="text" v-html="element.propValue" @input="handleInput" :style="{ verticalAlign: element.style.verticalAlign }" ></div> </div> <div v-else class="v-text"> <div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"></div> </div> </template> <script> import { mapState } from 'vuex' import { keycodes } from '@/utils/shortcutKey.js' export default { props: { propValue: { type: String, require: true, }, element: { type: Object, }, }, data() { return { canEdit: false, ctrlKey: 17, isCtrlDown: false, } }, computed: { ...mapState([ 'editMode', ]), }, methods: { handleInput(e) { this.$emit('input', this.element, e.target.innerHTML) }, handleKeydown(e) { if (e.keyCode == this.ctrlKey) { this.isCtrlDown = true } else if (this.isCtrlDown && this.canEdit && keycodes.includes(e.keyCode)) { e.stopPropagation() } else if (e.keyCode == 46) { // deleteKey e.stopPropagation() } }, handleKeyup(e) { if (e.keyCode == this.ctrlKey) { this.isCtrlDown = false } }, handleMousedown(e) { if (this.canEdit) { e.stopPropagation() } }, clearStyle(e) { e.preventDefault() const clp = e.clipboardData const text = clp.getData('text/plain') || '' if (text !== '') { document.execCommand('insertText', false, text) } this.$emit('input', this.element, e.target.innerHTML) }, handleBlur(e) { this.element.propValue = e.target.innerHTML || ' ' this.canEdit = false }, setEdit() { this.canEdit = true // 全选 this.selectText(this.$refs.text) }, selectText(element) { const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(element) selection.removeAllRanges() selection.addRange(range) }, }, } </script> <style lang="scss" scoped> .v-text { width: 100%; height: 100%; display: table; div { display: table-cell; width: 100%; height: 100%; outline: none; } .canEdit { cursor: text; height: 100%; } } </style>
改造后的 VText
组件功能如下:
- 双击启动编辑。
- 支持选中文本。
- 粘贴时过滤掉文本的样式。
- 换行时自动扩充文本框的高度。
20. 矩形组件
矩形组件其实就是一个内嵌 VText
文本组件的一个 DIV。
<template> <div class="rect-shape"> <v-text :propValue="element.propValue" :element="element" /> </div> </template> <script> export default { props: { element: { type: Object, }, }, } </script> <style lang="scss" scoped> .rect-shape { width: 100%; height: 100%; overflow: auto; } </style>
VText
文本组件有的功能它都有,并且可以任意放大缩小。
21. 锁定组件
锁定组件主要是看到 processon
和墨刀有这个功能,于是我顺便实现了。锁定组件的具体需求为:不能移动、放大缩小、旋转、复制、粘贴等,只能进行解锁操作。
它的实现原理也不难:
- 在自定义组件上加一个
isLock
属性,表示是否锁定组件。 - 在点击组件时,根据
isLock
是否为true
来隐藏组件上的八个点和旋转图标。 - 为了突出一个组件被锁定,给它加上透明度属性和一个锁的图标。
- 如果组件被锁定,置灰上面所说的需求对应的按钮,不能被点击。
相关代码如下:
export const commonAttr = { animations: [], events: {}, groupStyle: {}, // 当一个组件成为 Group 的子组件时使用 isLock: false, // 是否锁定组件 }
<el-button @click="decompose" :disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">拆分</el-button> <el-button @click="lock" :disabled="!curComponent || curComponent.isLock">锁定</el-button> <el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">解锁</el-button>
<template> <div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }"> <ul @mouseup="handleMouseUp"> <template v-if="curComponent"> <template v-if="!curComponent.isLock"> <li @click="copy">复制</li> <li @click="paste">粘贴</li> <li @click="cut">剪切</li> <li @click="deleteComponent">删除</li> <li @click="lock">锁定</li> <li @click="topComponent">置顶</li> <li @click="bottomComponent">置底</li> <li @click="upComponent">上移</li> <li @click="downComponent">下移</li> </template> <li v-else @click="unlock">解锁</li> </template> <li v-else @click="paste">粘贴</li> </ul> </div> </template>
22. 快捷键
支持快捷键主要是为了提升开发效率,用鼠标点点点毕竟没有按键盘快。目前快捷键支持的功能如下:
const ctrlKey = 17, vKey = 86, // 粘贴 cKey = 67, // 复制 xKey = 88, // 剪切 yKey = 89, // 重做 zKey = 90, // 撤销 gKey = 71, // 组合 bKey = 66, // 拆分 lKey = 76, // 锁定 uKey = 85, // 解锁 sKey = 83, // 保存 pKey = 80, // 预览 dKey = 68, // 删除 deleteKey = 46, // 删除 eKey = 69 // 清空画布
实现原理主要是利用 window 全局监听按键事件,在符合条件的按键触发时执行对应的操作:
// 与组件状态无关的操作 const basemap = { [vKey]: paste, [yKey]: redo, [zKey]: undo, [sKey]: save, [pKey]: preview, [eKey]: clearCanvas, } // 组件锁定状态下可以执行的操作 const lockMap = { ...basemap, [uKey]: unlock, } // 组件未锁定状态下可以执行的操作 const unlockMap = { ...basemap, [cKey]: copy, [xKey]: cut, [gKey]: compose, [bKey]: decompose, [dKey]: deleteComponent, [deleteKey]: deleteComponent, [lKey]: lock, } let isCtrlDown = false // 全局监听按键操作并执行相应命令 export function listenGlobalKeyDown() { window.onkeydown = (e) => { const { curComponent } = store.state if (e.keyCode == ctrlKey) { isCtrlDown = true } else if (e.keyCode == deleteKey && curComponent) { store.commit('deleteComponent') store.commit('recordSnapshot') } else if (isCtrlDown) { if (!curComponent || !curComponent.isLock) { e.preventDefault() unlockMap[e.keyCode] && unlockMap[e.keyCode]() } else if (curComponent && curComponent.isLock) { e.preventDefault() lockMap[e.keyCode] && lockMap[e.keyCode]() } } } window.onkeyup = (e) => { if (e.keyCode == ctrlKey) { isCtrlDown = false } } }
为了防止和浏览器默认快捷键冲突,所以需要加上 e.preventDefault()
。
23. 网格线
网格线功能使用 SVG 来实现:
<template> <svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"> <defs> <pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse"> <path d="M 7.236328125 0 L 0 0 0 7.236328125" fill="none" stroke="rgba(207, 207, 207, 0.3)" stroke-width="1"> </path> </pattern> <pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse"> <rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"></rect> <path d="M 36.181640625 0 L 0 0 0 36.181640625" fill="none" stroke="rgba(186, 186, 186, 0.5)" stroke-width="1"> </path> </pattern> </defs> <rect width="100%" height="100%" fill="url(#grid)"></rect> </svg> </template> <style lang="scss" scoped> .grid { position: absolute; top: 0; left: 0; } </style>
对 SVG 不太懂的,建议看一下 MDN 的教程。
24. 编辑器快照的另一种实现方式
在系列文章的第一篇中,我已经分析过快照的实现原理。
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
,保存的快照数据越多占用的内存就越多。对此有两个解决方案:
- 限制快照步数,例如只能保存 50 步的快照数据。
- 保存快照只保存差异部分。
现在详细描述一下第二个解决方案。
假设依次往画布上添加 a b c d 四个组件,在原来的实现中,对应的 snapshotData
数据为:
// snapshotData [ [a], [a, b], [a, b, c], [a, b, c, d], ]
从上面的代码可以发现,每一相邻的快照中,只有一个数据是不同的。所以我们可以为每一步的快照添加一个类型字段,用来表示此次操作是添加还是删除。
那么上面添加四个组件的操作,所对应的 snapshotData
数据为:
// snapshotData [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], ]
如果我们要删除 c 组件,那么 snapshotData
数据将变为:
// snapshotData [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], [{ type: 'remove', value: c }], ]
那如何使用现在的快照数据呢?
我们需要遍历一遍快照数据,来生成编辑器的组件数据 componentData
。假设在上面的数据基础上执行了 undo
撤销操作:
// snapshotData // 快照索引 snapshotIndex 此时为 3 [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], [{ type: 'remove', value: c }], ]
snapshotData[0]
类型为add
,将组件 a 添加到componentData
中,此时componentData
为[a]
- 依次类推
[a, b]
[a, b, c]
[a, b, c, d]
如果这时执行 redo
重做操作,快照索引 snapshotIndex
变为 4。对应的快照数据类型为 type: 'remove'
, 移除组件 c。则数组数据为 [a, b, d]
。
这种方法其实就是时间换空间,虽然每一次保存的快照数据只有一项,但每次都得遍历一遍所有的快照数据。两种方法都不完美,要使用哪种取决于你,目前我仍在使用第一种方法。
总结
从造轮子的角度来看,这是我目前造的第四个比较满意的轮子,其他三个为:
造轮子是一个很好的提升自己技术水平的方法,但造轮子一定要造有意义、有难度的轮子,并且同类型的轮子只造一个。造完轮子后,还需要写总结,最好输出成文章分享出去。