<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          可視化拖拽組件庫一些技術要點原理分析

          共 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.y
          const startX = e.clientXconst startY = e.clientYthis.start.x = startX - this.editorXthis.start.y = startY - this.editorY// 展示選中區(qū)域this.isShowArea = true
          const 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 = Infinity let right = -Infinity, bottom = -Infinity areaData.forEach(component => { let style = {} if (component.component == 'Group') { component.propValue.forEach(item => { const rectInfo = $(`#component${item.id}`).getBoundingClientRect() style.left = rectInfo.left - this.editorX style.top = rectInfo.top - this.editorY style.right = rectInfo.right - this.editorX style.bottom = rectInfo.bottom - this.editorY
          if (style.left < left) left = style.left if (style.top < top) top = style.top if (style.right > right) right = style.right if (style.bottom > bottom) bottom = style.bottom }) } else { style = getComponentRotatedStyle(component.style) }
          if (style.left < left) left = style.left if (style.top < top) top = style.top if (style.right > right) right = style.right if (style.bottom > bottom) bottom = style.bottom })
          this.start.x = left this.start.y = top this.width = right - left this.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) return const { left, top, width, height } = component.style if (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 bottom2.對比每個組件的這四個信息,取得選中區(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">                <component                    class="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.style this.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.style    this.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.height
          const 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 } = pointInfo    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
          const newWidth = newBottomRightPoint.x - newTopLeftPoint.x const newHeight = 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) }}

          現(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 } = pointInfo    let newCenterPoint = getCenterPoint(curPositon, symmetricPoint)    let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)    let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
          let newWidth = newBottomRightPoint.x - newTopLeftPoint.x let newHeight = newBottomRightPoint.y - newTopLeftPoint.y
          if (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.x newHeight = 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 false    const 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.propValue    const 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.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 = {}}

          這段代碼的處理邏輯為:

          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) { // 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 組件功能如下:

          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.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. 網(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">                <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 的教程[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/


          瀏覽 96
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  国产精品毛片一区视频播 | 国产一区二区三区视频在线播放 | 2025天天操 | 日本成人黄色网址 | 做爱网站插进去网站. |