可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析
共 32461字,需瀏覽 65分鐘
·
2024-05-27 09:21
本文是可視化拖拽系列的第三篇,之前的兩篇文章一共對(duì) 17 個(gè)功能點(diǎn)的技術(shù)原理進(jìn)行了分析:
1.編輯器2.自定義組件3.拖拽4.刪除組件、調(diào)整圖層層級(jí)5.放大縮小6.撤消、重做7.組件屬性設(shè)置8.吸附9.預(yù)覽、保存代碼10.綁定事件11.綁定動(dòng)畫12.導(dǎo)入 PSD13.手機(jī)模式14.拖拽旋轉(zhuǎn)15.復(fù)制粘貼剪切16.數(shù)據(jù)交互17.發(fā)布
本文在此基礎(chǔ)上,將對(duì)以下幾個(gè)功能點(diǎn)的技術(shù)原理進(jìn)行分析:
1.多個(gè)組件的組合和拆分2.文本組件3.矩形組件4.鎖定組件5.快捷鍵6.網(wǎng)格線7.編輯器快照的另一種實(shí)現(xiàn)方式
如果你對(duì)我之前的兩篇文章不是很了解,建議先把這兩篇文章看一遍,再來閱讀此文:
?可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析[1]?可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(二)[2]
雖然我這個(gè)可視化拖拽組件庫只是一個(gè) DEMO,但對(duì)比了一下市面上的一些現(xiàn)成產(chǎn)品(例如 processon[3]、墨刀[4]),就基礎(chǔ)功能來說,我這個(gè) DEMO 實(shí)現(xiàn)了絕大部分的功能。
如果你對(duì)于低代碼平臺(tái)有興趣,但又不了解的話。強(qiáng)烈建議將我的三篇文章結(jié)合項(xiàng)目源碼一起閱讀,相信對(duì)你的收獲絕對(duì)不小。另附上項(xiàng)目、在線 DEMO 地址:
?項(xiàng)目地址[5]?在線 DEMO[6]
18. 多個(gè)組件的組合和拆分
組合和拆分的技術(shù)點(diǎn)相對(duì)來說比較多,共有以下 4 個(gè):
?選中區(qū)域?組合后的移動(dòng)、旋轉(zhuǎn)?組合后的放大縮小?拆分后子組件樣式的恢復(fù)
選中區(qū)域
在將多個(gè)組件組合之前,需要先選中它們。利用鼠標(biāo)事件可以很方便的將選中區(qū)域展示出來:
1.mousedown 記錄起點(diǎn)坐標(biāo)2.mousemove 將當(dāng)前坐標(biāo)和起點(diǎn)坐標(biāo)進(jìn)行計(jì)算得出移動(dòng)區(qū)域3.如果按下鼠標(biāo)后往左上方移動(dòng),類似于這種操作則需要將當(dāng)前坐標(biāo)設(shè)為起點(diǎn)坐標(biāo),再計(jì)算出移動(dòng)區(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ā)時(shí),需要對(duì)選中區(qū)域內(nèi)的所有組件的位移大小信息進(jìn)行計(jì)算,得出一個(gè)能包含區(qū)域內(nèi)所有組件的最小區(qū)域。這個(gè)效果如下圖所示:
這個(gè)計(jì)算過程的代碼:
createGroup() {// 獲取選中區(qū)域的組件數(shù)據(jù)const areaData = this.getSelectArea()if (areaData.length <= 1) {this.hideArea()return}// 根據(jù)選中區(qū)域和區(qū)域中每個(gè)組件的位移信息來創(chuàng)建 Group 組件// 要遍歷選擇區(qū)域的每個(gè)組件,獲取它們的 left top right bottom 信息來進(jìn)行比較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// 設(shè)置選中區(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ū)域起點(diǎn)坐標(biāo)const { x, y } = this.start// 計(jì)算所有的組件數(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}
簡(jiǎn)單描述一下這段代碼的處理邏輯:
1.利用 getBoundingClientRect()[7] 瀏覽器 API 獲取每個(gè)組件相對(duì)于瀏覽器視口四個(gè)方向上的信息,也就是 left top right bottom。2.對(duì)比每個(gè)組件的這四個(gè)信息,取得選中區(qū)域的最左、最上、最右、最下四個(gè)方向的數(shù)值,從而得出一個(gè)能包含區(qū)域內(nèi)所有組件的最小區(qū)域。3.如果選中區(qū)域內(nèi)已經(jīng)有一個(gè) Group 組合組件,則需要對(duì)它里面的子組件進(jìn)行計(jì)算,而不是對(duì)組合組件進(jìn)行計(jì)算。
組合后的移動(dòng)、旋轉(zhuǎn)
為了方便將多個(gè)組件一起進(jìn)行移動(dòng)、旋轉(zhuǎn)、放大縮小等操作,我新創(chuàng)建了一個(gè) 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 是相對(duì)于 group 組件的位置// 如果已存在 component.groupStyle,說明已經(jīng)計(jì)算過一次了。不需要再次計(jì)算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 組件時(shí),獲取每個(gè)子組件在 Group 組件內(nèi)的相對(duì)位移和相對(duì)大小:
created() {const parentStyle = this.element.stylethis.propValue.forEach(component => {// component.groupStyle 的 top left 是相對(duì)于 group 組件的位置// 如果已存在 component.groupStyle,說明已經(jīng)計(jì)算過一次了。不需要再次計(jì)算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é)尾的相對(duì)數(shù)值。
為什么不使用絕對(duì)數(shù)值?
如果使用絕對(duì)數(shù)值,那么在移動(dòng) Group 組件時(shí),除了對(duì) Group 組件的屬性進(jìn)行計(jì)算外,還需要對(duì)它的每個(gè)子組件進(jìn)行計(jì)算。并且 Group 包含子組件太多的話,在進(jìn)行移動(dòng)、放大縮小時(shí),計(jì)算量會(huì)非常大,有可能會(huì)造成頁面卡頓。如果改成相對(duì)數(shù)值,則只需要在 Group 創(chuàng)建時(shí)計(jì)算一次。然后在 Group 組件進(jìn)行移動(dòng)、旋轉(zhuǎn)時(shí)也不用管 Group 的子組件,只對(duì)它自己計(jì)算即可。
組合后的放大縮小
組合后的放大縮小是個(gè)大問題,主要是因?yàn)橛行D(zhuǎn)角度的存在。首先來看一下各個(gè)子組件沒旋轉(zhuǎn)時(shí)的放大縮?。?/p>
從動(dòng)圖可以看出,效果非常完美。各個(gè)子組件的大小是跟隨 Group 組件的大小而改變的。
現(xiàn)在試著給子組件加上旋轉(zhuǎn)角度,再看一下效果:
為什么會(huì)出現(xiàn)這個(gè)問題?
主要是因?yàn)橐粋€(gè)組件無論旋不旋轉(zhuǎn),它的 top left 屬性都是不變的。這樣就會(huì)有一個(gè)問題,雖然實(shí)際上組件的 top left width height 屬性沒有變化。但在外觀上卻發(fā)生了變化。下面是兩個(gè)同樣的組件:一個(gè)沒旋轉(zhuǎn),一個(gè)旋轉(zhuǎn)了 45 度。
可以看出來旋轉(zhuǎn)后按鈕的 top left width height 屬性和我們從外觀上看到的是不一樣的。
接下來再看一個(gè)具體的示例:
上面是一個(gè) Group 組件,它左邊的子組件屬性為:
transform: rotate(-75.1967deg);width: 51.2267%;height: 32.2679%;top: 33.8661%;left: -10.6496%;
可以看到 width 的值為 51.2267%,但從外觀上來看,這個(gè)子組件最多占 Group 組件寬度的三分之一。所以這就是放大縮小不正常的問題所在。
一個(gè)不可行的解決方案(不想看的可以跳過)
一開始我想的是,先算出它相對(duì)瀏覽器視口的 top left width height 屬性,再算出這幾個(gè)屬性在 Group 組件上的相對(duì)數(shù)值。這可以通過 getBoundingClientRect() API 實(shí)現(xiàn)。只要維持外觀上的各個(gè)屬性占比不變,這樣 Group 組件在放大縮小時(shí),再通過旋轉(zhuǎn)角度,利用旋轉(zhuǎn)矩陣的知識(shí)(這一點(diǎn)在第二篇有詳細(xì)描述)獲取它未旋轉(zhuǎn)前的 top left width height 屬性。這樣就可以做到子組件動(dòng)態(tài)調(diào)整了。
但是這有個(gè)問題,通過 getBoundingClientRect() API 只能獲取組件外觀上的 top left right bottom width height 屬性。再加上一個(gè)角度,參數(shù)還是不夠,所以無法計(jì)算出組件實(shí)際的 top left width height 屬性。
就像上面的這張圖,只知道原點(diǎn) O(x,y) w h 和旋轉(zhuǎn)角度,無法算出按鈕的寬高。
一個(gè)可行的解決方案
這是無意中發(fā)現(xiàn)的,我在對(duì) Group 組件進(jìn)行放大縮小時(shí),發(fā)現(xiàn)只要保持 Group 組件的寬高比例,子組件就能做到根據(jù)比例放大縮小。那么現(xiàn)在問題就轉(zhuǎn)變成了如何讓 Group 組件放大縮小時(shí)保持寬高比例。我在網(wǎng)上找到了這一篇文章[8],它詳細(xì)描述了一個(gè)旋轉(zhuǎn)組件如何保持寬高比來進(jìn)行放大縮小,并配有源碼示例。
現(xiàn)在我嘗試簡(jiǎn)單描述一下如何保持寬高比對(duì)一個(gè)旋轉(zhuǎn)組件進(jìn)行放大縮?。ńㄗh還是看看原文)。下面是一個(gè)已旋轉(zhuǎn)一定角度的矩形,假設(shè)現(xiàn)在拖動(dòng)它左上方的點(diǎn)進(jìn)行拉伸。
第一步,算出組件寬高比,以及按下鼠標(biāo)時(shí)通過組件的坐標(biāo)(無論旋轉(zhuǎn)多少度,組件的 top left 屬性不變)和大小算出組件中心點(diǎn):
// 組件寬高比const proportion = style.width / style.heightconst center = {x: style.left + style.width / 2,y: style.top + style.height / 2,}
第二步,用當(dāng)前點(diǎn)擊坐標(biāo)和組件中心點(diǎn)算出當(dāng)前點(diǎn)擊坐標(biāo)的對(duì)稱點(diǎn)坐標(biāo):
// 獲取畫布位移信息const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()// 當(dāng)前點(diǎn)擊坐標(biāo)const curPoint = {x: e.clientX - editorRectInfo.left,y: e.clientY - editorRectInfo.top,}// 獲取對(duì)稱點(diǎn)的坐標(biāo)const symmetricPoint = {x: center.x - (curPoint.x - center.x),y: center.y - (curPoint.y - center.y),}
第三步,摁住組件左上角進(jìn)行拉伸時(shí),通過當(dāng)前鼠標(biāo)實(shí)時(shí)坐標(biāo)和對(duì)稱點(diǎn)計(jì)算出新的組件中心點(diǎn):
const curPositon = {x: moveEvent.clientX - editorRectInfo.left,y: moveEvent.clientY - editorRectInfo.top,}const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)// 求兩點(diǎn)之間的中點(diǎn)坐標(biāo)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),即使你知道了拉伸時(shí)移動(dòng)的 xy 距離,也不能直接對(duì)組件進(jìn)行計(jì)算。否則就會(huì)出現(xiàn) BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉(zhuǎn)的情況下對(duì)其進(jìn)行計(jì)算。
第四步,根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點(diǎn)、當(dāng)前鼠標(biāo)實(shí)時(shí)坐標(biāo)可以算出當(dāng)前鼠標(biāo)實(shí)時(shí)坐標(biāo) currentPosition 在未旋轉(zhuǎn)時(shí)的坐標(biāo) newTopLeftPoint。同時(shí)也能根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點(diǎn)、對(duì)稱點(diǎn)算出組件對(duì)稱點(diǎn) sPoint 在未旋轉(zhuǎn)時(shí)的坐標(biāo) newBottomRightPoint。
對(duì)應(yīng)的計(jì)算公式如下:
/*** 計(jì)算根據(jù)圓心旋轉(zhuǎn)后的點(diǎn)的坐標(biāo)* @param {Object} point 旋轉(zhuǎn)前的點(diǎn)坐標(biāo)* @param {Object} center 旋轉(zhuǎn)中心* @param {Number} rotate 旋轉(zhuǎn)的角度* @return {Object} 旋轉(zhuǎn)后的坐標(biāo)* https://www.zhihu.com/question/67425734/answer/252724399 旋轉(zhuǎn)矩陣公式*/export function calculateRotatedPointCoordinate(point, center, rotate) {/*** 旋轉(zhuǎn)公式:* 點(diǎn)a(x, y)* 旋轉(zhuǎn)中心c(x, y)* 旋轉(zhuǎn)后點(diǎ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)矩陣的知識(shí),對(duì)于一個(gè)沒上過大學(xué)的人來說,實(shí)在太難了。還好我從知乎上的一個(gè)回答[9]中找到了這一公式的推理過程,下面是回答的原文:
通過以上幾個(gè)計(jì)算值,就可以得到組件新的位移值 top left 以及新的組件大小。對(duì)應(yīng)的完整代碼如下:
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)后的放大縮?。?/p>
第五步,由于我們現(xiàn)在需要的是鎖定寬高比來進(jìn)行放大縮小,所以需要重新計(jì)算拉伸后的圖形的左上角坐標(biāo)。
這里先確定好幾個(gè)形狀的命名:
?原圖形: 紅色部分?新圖形: 藍(lán)色部分?修正圖形: 綠色部分,即加上寬高比鎖定規(guī)則的修正圖形
在第四步中算出組件未旋轉(zhuǎn)前的 newTopLeftPoint newBottomRightPoint newWidth newHeight 后,需要根據(jù)寬高比 proportion 來算出新的寬度或高度。
上圖就是一個(gè)需要改變高度的示例,計(jì)算過程如下:
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)前的坐標(biāo)是以沒按比例縮減寬高前的坐標(biāo)來計(jì)算的,所以縮減寬高后,需要按照原來的中心點(diǎn)旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對(duì)應(yīng)的坐標(biāo)。然后以這個(gè)坐標(biāo)和對(duì)稱點(diǎn)獲得新的中心點(diǎn),并重新計(jì)算未旋轉(zhuǎn)前的坐標(biāo)。
經(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)前的坐標(biāo)是以沒按比例縮減寬高前的坐標(biāo)來計(jì)算的// 所以縮減寬高后,需要按照原來的中心點(diǎn)旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對(duì)應(yīng)的坐標(biāo)// 然后以這個(gè)坐標(biāo)和對(duì)稱點(diǎn)獲得新的中心點(diǎn),并重新計(jì)算未旋轉(zhuǎn)前的坐標(biāo)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)}}
保持寬高比進(jìn)行放大縮小的效果如下:
當(dāng) Group 組件有旋轉(zhuǎn)的子組件時(shí),才需要保持寬高比進(jìn)行放大縮小。所以在創(chuàng)建 Group 組件時(shí)可以判斷一下子組件是否有旋轉(zhuǎn)角度。如果沒有,就不需要保持寬度比進(jì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}
拆分后子組件樣式的恢復(fù)
將多個(gè)組件組合在一起只是第一步,第二步是將 Group 組件進(jìn)行拆分并恢復(fù)各個(gè)子組件的樣式。保證拆分后的子組件在外觀上的屬性不變。
計(jì)算代碼如下:
// 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 })})}// 將組合中的各個(gè)子組件拆分出來,并計(jì)算它們新的 styleexport default function decomposeComponent(component, editorRect, parentStyle) {// 子組件相對(duì)于瀏覽器視口的樣式const componentRect = $(`#component${component.id}`).getBoundingClientRect()// 獲取元素的中心點(diǎn)坐標(biāo)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// 計(jì)算出元素新的 top left 坐標(biāo)component.style.left = center.x - component.style.width / 2component.style.top = center.y - component.style.height / 2component.groupStyle = {}}
這段代碼的處理邏輯為:
1.遍歷 Group 的子組件并恢復(fù)它們的樣式2.利用 getBoundingClientRect() API 獲取子組件相對(duì)于瀏覽器視口的 left top width height 屬性。3.利用這四個(gè)屬性計(jì)算出子組件的中心點(diǎn)坐標(biāo)。4.由于子組件的 width height 屬性是相對(duì)于 Group 組件的,所以將它們的百分比值和 Group 相乘得出具體數(shù)值。5.再用中心點(diǎn) center(x, y) 減去子組件寬高的一半得出它的 left top 屬性。
至此,組合和拆分就講解完了。
19. 文本組件
文本組件 VText 之前就已經(jīng)實(shí)現(xiàn)過了,但不完美。例如無法對(duì)文字進(jìn)行選中?,F(xiàn)在我對(duì)它進(jìn)行了重寫,讓它支持選中功能。
<template><div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup"><!-- tabindex >= 0 使得雙擊時(shí)聚集該元素 --><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.雙擊啟動(dòng)編輯。2.支持選中文本。3.粘貼時(shí)過濾掉文本的樣式。4.換行時(shí)自動(dòng)擴(kuò)充文本框的高度。
20. 矩形組件
矩形組件其實(shí)就是一個(gè)內(nèi)嵌 VText 文本組件的一個(gè) 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 和墨刀有這個(gè)功能,于是我順便實(shí)現(xiàn)了。鎖定組件的具體需求為:不能移動(dòng)、放大縮小、旋轉(zhuǎn)、復(fù)制、粘貼等,只能進(jìn)行解鎖操作。
它的實(shí)現(xiàn)原理也不難:
1.在自定義組件上加一個(gè) isLock 屬性,表示是否鎖定組件。2.在點(diǎn)擊組件時(shí),根據(jù) isLock 是否為 true 來隱藏組件上的八個(gè)點(diǎn)和旋轉(zhuǎn)圖標(biāo)。3.為了突出一個(gè)組件被鎖定,給它加上透明度屬性和一個(gè)鎖的圖標(biāo)。4.如果組件被鎖定,置灰上面所說的需求對(duì)應(yīng)的按鈕,不能被點(diǎn)擊。
相關(guān)代碼如下:
export const commonAttr = {animations: [],events: {},groupStyle: {}, // 當(dāng)一個(gè)組件成為 Group 的子組件時(shí)使用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">復(fù)制</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ā)效率,用鼠標(biāo)點(diǎn)點(diǎn)點(diǎn)畢竟沒有按鍵盤快。目前快捷鍵支持的功能如下:
const ctrlKey = 17,vKey = 86, // 粘貼cKey = 67, // 復(fù)制xKey = 88, // 剪切yKey = 89, // 重做zKey = 90, // 撤銷gKey = 71, // 組合bKey = 66, // 拆分lKey = 76, // 鎖定uKey = 85, // 解鎖sKey = 83, // 保存pKey = 80, // 預(yù)覽dKey = 68, // 刪除deleteKey = 46, // 刪除eKey = 69 // 清空畫布
實(shí)現(xiàn)原理主要是利用 window 全局監(jiān)聽按鍵事件,在符合條件的按鍵觸發(fā)時(shí)執(zhí)行對(duì)應(yīng)的操作:
// 與組件狀態(tài)無關(guān)的操作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í)行相應(yīng)命令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}}}
為了防止和瀏覽器默認(rèn)快捷鍵沖突,所以需要加上 e.preventDefault()。
23. 網(wǎng)格線
網(wǎng)格線功能使用 SVG 來實(shí)現(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>
對(duì) SVG 不太懂的,建議看一下 MDN 的教程[10]。
24. 編輯器快照的另一種實(shí)現(xiàn)方式
在系列文章的第一篇中,我已經(jīng)分析過快照的實(shí)現(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 過程中,添加新的快照時(shí),要將它后面的快照清理掉if (state.snapshotIndex < state.snapshotData.length - 1) {state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)}},
用一個(gè)數(shù)組來保存編輯器的快照數(shù)據(jù)。保存快照就是不停地執(zhí)行 push() 操作,將當(dāng)前的編輯器數(shù)據(jù)推入 snapshotData 數(shù)組,并增加快照索引 snapshotIndex。
由于每一次添加快照都是將當(dāng)前編輯器的所有組件數(shù)據(jù)推入 snapshotData,保存的快照數(shù)據(jù)越多占用的內(nèi)存就越多。對(duì)此有兩個(gè)解決方案:
1.限制快照步數(shù),例如只能保存 50 步的快照數(shù)據(jù)。2.保存快照只保存差異部分。
現(xiàn)在詳細(xì)描述一下第二個(gè)解決方案。
假設(shè)依次往畫布上添加 a b c d 四個(gè)組件,在原來的實(shí)現(xiàn)中,對(duì)應(yīng)的 snapshotData 數(shù)據(jù)為:
// snapshotData[[a],[a, b],[a, b, c],[a, b, c, d],]
從上面的代碼可以發(fā)現(xiàn),每一相鄰的快照中,只有一個(gè)數(shù)據(jù)是不同的。所以我們可以為每一步的快照添加一個(gè)類型字段,用來表示此次操作是添加還是刪除。
那么上面添加四個(gè)組件的操作,所對(duì)應(yīng)的 snapshotData 數(shù)據(jù)為:
// snapshotData[[{ type: 'add', value: a }],[{ type: 'add', value: b }],[{ type: 'add', value: c }],[{ type: 'add', value: d }],]
如果我們要?jiǎng)h除 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è)在上面的數(shù)據(jù)基礎(chǔ)上執(zhí)行了 undo 撤銷操作:
// snapshotData// 快照索引 snapshotIndex 此時(shí)為 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 中,此時(shí) componentData 為 [a]2.依次類推 [a, b]3.[a, b, c]4.[a, b, c, d]
如果這時(shí)執(zhí)行 redo 重做操作,快照索引 snapshotIndex 變?yōu)?4。對(duì)應(yīng)的快照數(shù)據(jù)類型為 type: 'remove', 移除組件 c。則數(shù)組數(shù)據(jù)為 [a, b, d]。
這種方法其實(shí)就是時(shí)間換空間,雖然每一次保存的快照數(shù)據(jù)只有一項(xiàng),但每次都得遍歷一遍所有的快照數(shù)據(jù)。兩種方法都不完美,要使用哪種取決于你,目前我仍在使用第一種方法。
總結(jié)
從造輪子的角度來看,這是我目前造的第四個(gè)比較滿意的輪子,其他三個(gè)為:
?nand2tetris[11]?MIT6.828[12]?mini-vue[13]
造輪子是一個(gè)很好的提升自己技術(shù)水平的方法,但造輪子一定要造有意義、有難度的輪子,并且同類型的輪子只造一個(gè)。造完輪子后,還需要寫總結(jié),最好輸出成文章分享出去。
參考資料
?snapping-demo[14]?processon[15]?墨刀[16]
References
[1] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析: https://juejin.cn/post/6908502083075325959[2] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(二): https://juejin.cn/post/6918881497264947207[3] processon: https://www.processon.com/[4] 墨刀: https://modao.cc/[5] 項(xiàng)目地址: 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/
