可視化拖拽組件庫一些技術要點原理分析
共 32461字,需瀏覽 65分鐘
·
2024-05-27 09:21
本文是可視化拖拽系列的第三篇,之前的兩篇文章一共對 17 個功能點的技術原理進行了分析:
1.編輯器2.自定義組件3.拖拽4.刪除組件、調(diào)整圖層層級5.放大縮小6.撤消、重做7.組件屬性設置8.吸附9.預覽、保存代碼10.綁定事件11.綁定動畫12.導入 PSD13.手機模式14.拖拽旋轉(zhuǎn)15.復制粘貼剪切16.數(shù)據(jù)交互17.發(fā)布
本文在此基礎上,將對以下幾個功能點的技術原理進行分析:
1.多個組件的組合和拆分2.文本組件3.矩形組件4.鎖定組件5.快捷鍵6.網(wǎng)格線7.編輯器快照的另一種實現(xiàn)方式
如果你對我之前的兩篇文章不是很了解,建議先把這兩篇文章看一遍,再來閱讀此文:
?可視化拖拽組件庫一些技術要點原理分析[1]?可視化拖拽組件庫一些技術要點原理分析(二)[2]
雖然我這個可視化拖拽組件庫只是一個 DEMO,但對比了一下市面上的一些現(xiàn)成產(chǎn)品(例如 processon[3]、墨刀[4]),就基礎功能來說,我這個 DEMO 實現(xiàn)了絕大部分的功能。
如果你對于低代碼平臺有興趣,但又不了解的話。強烈建議將我的三篇文章結(jié)合項目源碼一起閱讀,相信對你的收獲絕對不小。另附上項目、在線 DEMO 地址:
?項目地址[5]?在線 DEMO[6]
18. 多個組件的組合和拆分
組合和拆分的技術點相對來說比較多,共有以下 4 個:
?選中區(qū)域?組合后的移動、旋轉(zhuǎn)?組合后的放大縮小?拆分后子組件樣式的恢復
選中區(qū)域
在將多個組件組合之前,需要先選中它們。利用鼠標事件可以很方便的將選中區(qū)域展示出來:
1.mousedown 記錄起點坐標2.mousemove 將當前坐標和起點坐標進行計算得出移動區(qū)域3.如果按下鼠標后往左上方移動,類似于這種操作則需要將當前坐標設為起點坐標,再計算出移動區(qū)域
// 獲取編輯器的位移信息const rectInfo = this.editor.getBoundingClientRect()this.editorX = rectInfo.xthis.editorY = rectInfo.yconst startX = e.clientXconst startY = e.clientYthis.start.x = startX - this.editorXthis.start.y = startY - this.editorY// 展示選中區(qū)域this.isShowArea = trueconst move = (moveEvent) => {this.width = Math.abs(moveEvent.clientX - startX)this.height = Math.abs(moveEvent.clientY - startY)if (moveEvent.clientX < startX) {this.start.x = moveEvent.clientX - this.editorX}if (moveEvent.clientY < startY) {this.start.y = moveEvent.clientY - this.editorY}}
在 mouseup 事件觸發(fā)時,需要對選中區(qū)域內(nèi)的所有組件的位移大小信息進行計算,得出一個能包含區(qū)域內(nèi)所有組件的最小區(qū)域。這個效果如下圖所示:
這個計算過程的代碼:
createGroup() {// 獲取選中區(qū)域的組件數(shù)據(jù)const areaData = this.getSelectArea()if (areaData.length <= 1) {this.hideArea()return}// 根據(jù)選中區(qū)域和區(qū)域中每個組件的位移信息來創(chuàng)建 Group 組件// 要遍歷選擇區(qū)域的每個組件,獲取它們的 left top right bottom 信息來進行比較let top = Infinity, left = Infinitylet right = -Infinity, bottom = -InfinityareaData.forEach(component => {let style = {}if (component.component == 'Group') {component.propValue.forEach(item => {const rectInfo = $(`#component${item.id}`).getBoundingClientRect()style.left = rectInfo.left - this.editorXstyle.top = rectInfo.top - this.editorYstyle.right = rectInfo.right - this.editorXstyle.bottom = rectInfo.bottom - this.editorYif (style.left < left) left = style.leftif (style.top < top) top = style.topif (style.right > right) right = style.rightif (style.bottom > bottom) bottom = style.bottom})} else {style = getComponentRotatedStyle(component.style)}if (style.left < left) left = style.leftif (style.top < top) top = style.topif (style.right > right) right = style.rightif (style.bottom > bottom) bottom = style.bottom})this.start.x = leftthis.start.y = topthis.width = right - leftthis.height = bottom - top// 設置選中區(qū)域位移大小信息和區(qū)域內(nèi)的組件數(shù)據(jù)this.$store.commit('setAreaData', {style: {left,top,width: this.width,height: this.height,},components: areaData,})},getSelectArea() {const result = []// 區(qū)域起點坐標const { x, y } = this.start// 計算所有的組件數(shù)據(jù),判斷是否在選中區(qū)域內(nèi)this.componentData.forEach(component => {if (component.isLock) returnconst { left, top, width, height } = component.styleif (x <= left && y <= top && (left + width <= x + this.width) && (top + height <= y + this.height)) {result.push(component)}})// 返回在選中區(qū)域內(nèi)的所有組件return result}
簡單描述一下這段代碼的處理邏輯:
1.利用 getBoundingClientRect()[7] 瀏覽器 API 獲取每個組件相對于瀏覽器視口四個方向上的信息,也就是 left top right bottom。2.對比每個組件的這四個信息,取得選中區(qū)域的最左、最上、最右、最下四個方向的數(shù)值,從而得出一個能包含區(qū)域內(nèi)所有組件的最小區(qū)域。3.如果選中區(qū)域內(nèi)已經(jīng)有一個 Group 組合組件,則需要對它里面的子組件進行計算,而不是對組合組件進行計算。
組合后的移動、旋轉(zhuǎn)
為了方便將多個組件一起進行移動、旋轉(zhuǎn)、放大縮小等操作,我新創(chuàng)建了一個 Group 組合組件:
<template><div class="group"><div><template v-for="item in propValue"><componentclass="component":is="item.component":style="item.groupStyle":propValue="item.propValue":key="item.id":id="'component' + item.id":element="item"/></template></div></div></template><script>import { getStyle } from '@/utils/style'export default {props: {propValue: {type: Array,default: () => [],},element: {type: Object,},},created() {const parentStyle = this.element.stylethis.propValue.forEach(component => {// component.groupStyle 的 top left 是相對于 group 組件的位置// 如果已存在 component.groupStyle,說明已經(jīng)計算過一次了。不需要再次計算if (!Object.keys(component.groupStyle).length) {const style = { ...component.style }component.groupStyle = getStyle(style)component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)component.groupStyle.width = this.toPercent(style.width / parentStyle.width)component.groupStyle.height = this.toPercent(style.height / parentStyle.height)}})},methods: {toPercent(val) {return val * 100 + '%'},},}</script><style lang="scss" scoped>.group {& > div {position: relative;width: 100%;height: 100%;.component {position: absolute;}}}</style>
Group 組件的作用就是將區(qū)域內(nèi)的組件放到它下面,成為子組件。并且在創(chuàng)建 Group 組件時,獲取每個子組件在 Group 組件內(nèi)的相對位移和相對大小:
created() {const parentStyle = this.element.stylethis.propValue.forEach(component => {// component.groupStyle 的 top left 是相對于 group 組件的位置// 如果已存在 component.groupStyle,說明已經(jīng)計算過一次了。不需要再次計算if (!Object.keys(component.groupStyle).length) {const style = { ...component.style }component.groupStyle = getStyle(style)component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)component.groupStyle.width = this.toPercent(style.width / parentStyle.width)component.groupStyle.height = this.toPercent(style.height / parentStyle.height)}})},methods: {toPercent(val) {return val * 100 + '%'},},
也就是將子組件的 left top width height 等屬性轉(zhuǎn)成以 % 結(jié)尾的相對數(shù)值。
為什么不使用絕對數(shù)值?
如果使用絕對數(shù)值,那么在移動 Group 組件時,除了對 Group 組件的屬性進行計算外,還需要對它的每個子組件進行計算。并且 Group 包含子組件太多的話,在進行移動、放大縮小時,計算量會非常大,有可能會造成頁面卡頓。如果改成相對數(shù)值,則只需要在 Group 創(chuàng)建時計算一次。然后在 Group 組件進行移動、旋轉(zhuǎn)時也不用管 Group 的子組件,只對它自己計算即可。
組合后的放大縮小
組合后的放大縮小是個大問題,主要是因為有旋轉(zhuǎn)角度的存在。首先來看一下各個子組件沒旋轉(zhuǎn)時的放大縮小:
從動圖可以看出,效果非常完美。各個子組件的大小是跟隨 Group 組件的大小而改變的。
現(xiàn)在試著給子組件加上旋轉(zhuǎn)角度,再看一下效果:
為什么會出現(xiàn)這個問題?
主要是因為一個組件無論旋不旋轉(zhuǎn),它的 top left 屬性都是不變的。這樣就會有一個問題,雖然實際上組件的 top left width height 屬性沒有變化。但在外觀上卻發(fā)生了變化。下面是兩個同樣的組件:一個沒旋轉(zhuǎn),一個旋轉(zhuǎn)了 45 度。
可以看出來旋轉(zhuǎn)后按鈕的 top left width height 屬性和我們從外觀上看到的是不一樣的。
接下來再看一個具體的示例:
上面是一個 Group 組件,它左邊的子組件屬性為:
transform: rotate(-75.1967deg);width: 51.2267%;height: 32.2679%;top: 33.8661%;left: -10.6496%;
可以看到 width 的值為 51.2267%,但從外觀上來看,這個子組件最多占 Group 組件寬度的三分之一。所以這就是放大縮小不正常的問題所在。
一個不可行的解決方案(不想看的可以跳過)
一開始我想的是,先算出它相對瀏覽器視口的 top left width height 屬性,再算出這幾個屬性在 Group 組件上的相對數(shù)值。這可以通過 getBoundingClientRect() API 實現(xiàn)。只要維持外觀上的各個屬性占比不變,這樣 Group 組件在放大縮小時,再通過旋轉(zhuǎn)角度,利用旋轉(zhuǎn)矩陣的知識(這一點在第二篇有詳細描述)獲取它未旋轉(zhuǎn)前的 top left width height 屬性。這樣就可以做到子組件動態(tài)調(diào)整了。
但是這有個問題,通過 getBoundingClientRect() API 只能獲取組件外觀上的 top left right bottom width height 屬性。再加上一個角度,參數(shù)還是不夠,所以無法計算出組件實際的 top left width height 屬性。
就像上面的這張圖,只知道原點 O(x,y) w h 和旋轉(zhuǎn)角度,無法算出按鈕的寬高。
一個可行的解決方案
這是無意中發(fā)現(xiàn)的,我在對 Group 組件進行放大縮小時,發(fā)現(xiàn)只要保持 Group 組件的寬高比例,子組件就能做到根據(jù)比例放大縮小。那么現(xiàn)在問題就轉(zhuǎn)變成了如何讓 Group 組件放大縮小時保持寬高比例。我在網(wǎng)上找到了這一篇文章[8],它詳細描述了一個旋轉(zhuǎn)組件如何保持寬高比來進行放大縮小,并配有源碼示例。
現(xiàn)在我嘗試簡單描述一下如何保持寬高比對一個旋轉(zhuǎn)組件進行放大縮小(建議還是看看原文)。下面是一個已旋轉(zhuǎn)一定角度的矩形,假設現(xiàn)在拖動它左上方的點進行拉伸。
第一步,算出組件寬高比,以及按下鼠標時通過組件的坐標(無論旋轉(zhuǎn)多少度,組件的 top left 屬性不變)和大小算出組件中心點:
// 組件寬高比const proportion = style.width / style.heightconst 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),}}
由于組件處于旋轉(zhuǎn)狀態(tài),即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現(xiàn) BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉(zhuǎn)的情況下對其進行計算。
第四步,根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標 currentPosition 在未旋轉(zhuǎn)時的坐標 newTopLeftPoint。同時也能根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點、對稱點算出組件對稱點 sPoint 在未旋轉(zhuǎn)時的坐標 newBottomRightPoint。
對應的計算公式如下:
/*** 計算根據(jù)圓心旋轉(zhuǎn)后的點的坐標* @param {Object} point 旋轉(zhuǎn)前的點坐標* @param {Object} center 旋轉(zhuǎn)中心* @param {Number} rotate 旋轉(zhuǎn)的角度* @return {Object} 旋轉(zhuǎn)后的坐標* https://www.zhihu.com/question/67425734/answer/252724399 旋轉(zhuǎn)矩陣公式*/export function calculateRotatedPointCoordinate(point, center, rotate) {/*** 旋轉(zhuǎn)公式:* 點a(x, y)* 旋轉(zhuǎn)中心c(x, y)* 旋轉(zhuǎn)后點n(x, y)* 旋轉(zhuǎn)角度θ 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,}}
上面的公式涉及到線性代數(shù)中旋轉(zhuǎn)矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答[9]中找到了這一公式的推理過程,下面是回答的原文:
通過以上幾個計算值,就可以得到組件新的位移值 top left 以及新的組件大小。對應的完整代碼如下:
function calculateLeftTop(style, curPositon, pointInfo) {const { symmetricPoint } = pointInfoconst newCenterPoint = getCenterPoint(curPositon, symmetricPoint)const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)const newWidth = newBottomRightPoint.x - newTopLeftPoint.xconst newHeight = newBottomRightPoint.y - newTopLeftPoint.yif (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)}}
現(xiàn)在再來看一下旋轉(zhuǎn)后的放大縮小:
第五步,由于我們現(xiàn)在需要的是鎖定寬高比來進行放大縮小,所以需要重新計算拉伸后的圖形的左上角坐標。
這里先確定好幾個形狀的命名:
?原圖形: 紅色部分?新圖形: 藍色部分?修正圖形: 綠色部分,即加上寬高比鎖定規(guī)則的修正圖形
在第四步中算出組件未旋轉(zhuǎn)前的 newTopLeftPoint newBottomRightPoint newWidth newHeight 后,需要根據(jù)寬高比 proportion 來算出新的寬度或高度。
上圖就是一個需要改變高度的示例,計算過程如下:
if (newWidth / newHeight > proportion) {newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)newWidth = newHeight * proportion} else {newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)newHeight = newWidth / proportion}
由于現(xiàn)在求的未旋轉(zhuǎn)前的坐標是以沒按比例縮減寬高前的坐標來計算的,所以縮減寬高后,需要按照原來的中心點旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對應的坐標。然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉(zhuǎn)前的坐標。
經(jīng)過修改后的完整代碼如下:
function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {const { symmetricPoint } = pointInfolet newCenterPoint = getCenterPoint(curPositon, symmetricPoint)let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)let newWidth = newBottomRightPoint.x - newTopLeftPoint.xlet newHeight = newBottomRightPoint.y - newTopLeftPoint.yif (needLockProportion) {if (newWidth / newHeight > proportion) {newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)newWidth = newHeight * proportion} else {newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)newHeight = newWidth / proportion}// 由于現(xiàn)在求的未旋轉(zhuǎn)前的坐標是以沒按比例縮減寬高前的坐標來計算的// 所以縮減寬高后,需要按照原來的中心點旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對應的坐標// 然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉(zhuǎn)前的坐標const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate)newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint)newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate)newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)newWidth = newBottomRightPoint.x - newTopLeftPoint.xnewHeight = 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)}}
保持寬高比進行放大縮小的效果如下:
當 Group 組件有旋轉(zhuǎn)的子組件時,才需要保持寬高比進行放大縮小。所以在創(chuàng)建 Group 組件時可以判斷一下子組件是否有旋轉(zhuǎn)角度。如果沒有,就不需要保持寬度比進行放大縮小。
isNeedLockProportion() {if (this.element.component != 'Group') return falseconst ratates = [0, 90, 180, 360]for (const component of this.element.propValue) {if (!ratates.includes(mod360(parseInt(component.style.rotate)))) {return true}}return false}
拆分后子組件樣式的恢復
將多個組件組合在一起只是第一步,第二步是將 Group 組件進行拆分并恢復各個子組件的樣式。保證拆分后的子組件在外觀上的屬性不變。
計算代碼如下:
// storedecompose({ curComponent, editor }) {const parentStyle = { ...curComponent.style }const components = curComponent.propValueconst editorRect = editor.getBoundingClientRect()store.commit('deleteComponent')components.forEach(component => {decomposeComponent(component, editorRect, parentStyle)store.commit('addComponent', { component })})}// 將組合中的各個子組件拆分出來,并計算它們新的 styleexport 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.widthcomponent.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height// 計算出元素新的 top left 坐標component.style.left = center.x - component.style.width / 2component.style.top = center.y - component.style.height / 2component.groupStyle = {}}
這段代碼的處理邏輯為:
1.遍歷 Group 的子組件并恢復它們的樣式2.利用 getBoundingClientRect() API 獲取子組件相對于瀏覽器視口的 left top width height 屬性。3.利用這四個屬性計算出子組件的中心點坐標。4.由于子組件的 width height 屬性是相對于 Group 組件的,所以將它們的百分比值和 Group 相乘得出具體數(shù)值。5.再用中心點 center(x, y) 減去子組件寬高的一半得出它的 left top 屬性。
至此,組合和拆分就講解完了。
19. 文本組件
文本組件 VText 之前就已經(jīng)實現(xiàn)過了,但不完美。例如無法對文字進行選中。現(xiàn)在我對它進行了重寫,讓它支持選中功能。
<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) { // deleteKeye.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.clipboardDataconst 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 組件功能如下:
1.雙擊啟動編輯。2.支持選中文本。3.粘貼時過濾掉文本的樣式。4.換行時自動擴充文本框的高度。
20. 矩形組件
矩形組件其實就是一個內(nèi)嵌 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 和墨刀有這個功能,于是我順便實現(xiàn)了。鎖定組件的具體需求為:不能移動、放大縮小、旋轉(zhuǎn)、復制、粘貼等,只能進行解鎖操作。
它的實現(xiàn)原理也不難:
1.在自定義組件上加一個 isLock 屬性,表示是否鎖定組件。2.在點擊組件時,根據(jù) isLock 是否為 true 來隱藏組件上的八個點和旋轉(zhuǎn)圖標。3.為了突出一個組件被鎖定,給它加上透明度屬性和一個鎖的圖標。4.如果組件被鎖定,置灰上面所說的需求對應的按鈕,不能被點擊。
相關代碼如下:
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. 快捷鍵
支持快捷鍵主要是為了提升開發(fā)效率,用鼠標點點點畢竟沒有按鍵盤快。目前快捷鍵支持的功能如下:
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 // 清空畫布
實現(xiàn)原理主要是利用 window 全局監(jiān)聽按鍵事件,在符合條件的按鍵觸發(fā)時執(zhí)行對應的操作:
// 與組件狀態(tài)無關的操作const basemap = {[vKey]: paste,[yKey]: redo,[zKey]: undo,[sKey]: save,[pKey]: preview,[eKey]: clearCanvas,}// 組件鎖定狀態(tài)下可以執(zhí)行的操作const lockMap = {...basemap,[uKey]: unlock,}// 組件未鎖定狀態(tài)下可以執(zhí)行的操作const unlockMap = {...basemap,[cKey]: copy,[xKey]: cut,[gKey]: compose,[bKey]: decompose,[dKey]: deleteComponent,[deleteKey]: deleteComponent,[lKey]: lock,}let isCtrlDown = false// 全局監(jiān)聽按鍵操作并執(zhí)行相應命令export function listenGlobalKeyDown() {window.onkeydown = (e) => {const { curComponent } = store.stateif (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. 網(wǎng)格線
網(wǎng)格線功能使用 SVG 來實現(xiàn):
<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"><pathd="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><pathd="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 的教程[10]。
24. 編輯器快照的另一種實現(xiàn)方式
在系列文章的第一篇中,我已經(jīng)分析過快照的實現(xiàn)原理。
snapshotData: [], // 編輯器快照數(shù)據(jù)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)}},
用一個數(shù)組來保存編輯器的快照數(shù)據(jù)。保存快照就是不停地執(zhí)行 push() 操作,將當前的編輯器數(shù)據(jù)推入 snapshotData 數(shù)組,并增加快照索引 snapshotIndex。
由于每一次添加快照都是將當前編輯器的所有組件數(shù)據(jù)推入 snapshotData,保存的快照數(shù)據(jù)越多占用的內(nèi)存就越多。對此有兩個解決方案:
1.限制快照步數(shù),例如只能保存 50 步的快照數(shù)據(jù)。2.保存快照只保存差異部分。
現(xiàn)在詳細描述一下第二個解決方案。
假設依次往畫布上添加 a b c d 四個組件,在原來的實現(xiàn)中,對應的 snapshotData 數(shù)據(jù)為:
// snapshotData[[a],[a, b],[a, b, c],[a, b, c, d],]
從上面的代碼可以發(fā)現(xiàn),每一相鄰的快照中,只有一個數(shù)據(jù)是不同的。所以我們可以為每一步的快照添加一個類型字段,用來表示此次操作是添加還是刪除。
那么上面添加四個組件的操作,所對應的 snapshotData 數(shù)據(jù)為:
// snapshotData[[{ type: 'add', value: a }],[{ type: 'add', value: b }],[{ type: 'add', value: c }],[{ type: 'add', value: d }],]
如果我們要刪除 c 組件,那么 snapshotData 數(shù)據(jù)將變?yōu)椋?/p>
// snapshotData[ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], [{ type: 'remove', value: c }],]
那如何使用現(xiàn)在的快照數(shù)據(jù)呢?
我們需要遍歷一遍快照數(shù)據(jù),來生成編輯器的組件數(shù)據(jù) componentData。假設在上面的數(shù)據(jù)基礎上執(zhí)行了 undo 撤銷操作:
// snapshotData// 快照索引 snapshotIndex 此時為 3[[{ type: 'add', value: a }],[{ type: 'add', value: b }],[{ type: 'add', value: c }],[{ type: 'add', value: d }],[{ type: 'remove', value: c }],]
1.snapshotData[0] 類型為 add,將組件 a 添加到 componentData 中,此時 componentData 為 [a]2.依次類推 [a, b]3.[a, b, c]4.[a, b, c, d]
如果這時執(zhí)行 redo 重做操作,快照索引 snapshotIndex 變?yōu)?4。對應的快照數(shù)據(jù)類型為 type: 'remove', 移除組件 c。則數(shù)組數(shù)據(jù)為 [a, b, d]。
這種方法其實就是時間換空間,雖然每一次保存的快照數(shù)據(jù)只有一項,但每次都得遍歷一遍所有的快照數(shù)據(jù)。兩種方法都不完美,要使用哪種取決于你,目前我仍在使用第一種方法。
總結(jié)
從造輪子的角度來看,這是我目前造的第四個比較滿意的輪子,其他三個為:
?nand2tetris[11]?MIT6.828[12]?mini-vue[13]
造輪子是一個很好的提升自己技術水平的方法,但造輪子一定要造有意義、有難度的輪子,并且同類型的輪子只造一個。造完輪子后,還需要寫總結(jié),最好輸出成文章分享出去。
參考資料
?snapping-demo[14]?processon[15]?墨刀[16]
References
[1] 可視化拖拽組件庫一些技術要點原理分析: https://juejin.cn/post/6908502083075325959[2] 可視化拖拽組件庫一些技術要點原理分析(二): https://juejin.cn/post/6918881497264947207[3] processon: https://www.processon.com/[4] 墨刀: https://modao.cc/[5] 項目地址: https://github.com/woai3c/visual-drag-demo[6] 在線 DEMO: https://woai3c.gitee.io/visual-drag-demo[7] getBoundingClientRect(): https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect[8] 文章: https://github.com/shenhudong/snapping-demo/wiki/corner-handle[9] 回答: https://www.zhihu.com/question/67425734/answer/252724399[10] 教程: https://developer.mozilla.org/zh-CN/docs/Web/SVG[11] nand2tetris: https://github.com/woai3c/nand2tetris[12] MIT6.828: https://github.com/woai3c/MIT6.828[13] mini-vue: https://github.com/woai3c/mini-vue[14] snapping-demo: https://github.com/shenhudong/snapping-demo/wiki/corner-handle[15] processon: https://www.processon.com/[16] 墨刀: https://modao.cc/
