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

譚光志

本文是可視化拖拽系列的第三篇,之前的兩篇文章一共對 17 個功能點的技術原理進行了分析:

  1. 編輯器
  2. 自定義組件
  3. 拖拽
  4. 刪除組件、調整圖層層級
  5. 放大縮小
  6. 撤消、重做
  7. 組件屬性設置
  8. 吸附
  9. 預覽、保存代碼
  10. 綁定事件
  11. 綁定動畫
  12. 導入 PSD
  13. 手機模式
  14. 拖拽旋轉
  15. 復制粘貼剪切
  16. 數據交互
  17. 發布

本文在此基礎上,將對以下幾個功能點的技術原理進行分析:

  1. 多個組件的組合和拆分
  2. 文本組件
  3. 矩形組件
  4. 鎖定組件
  5. 快捷鍵
  6. 網格線
  7. 編輯器快照的另一種實現方式

如果你對我之前的兩篇文章不是很了解,建議先把這兩篇文章看一遍,再來閱讀此文:

雖然我這個可視化拖拽組件庫只是一個 DEMO,但對比了一下市面上的一些現成產品(例如 processon、墨刀),就基礎功能來說,我這個 DEMO 實現了絕大部分的功能。

如果你對于低代碼平臺有興趣,但又不了解的話。強烈建議將我的三篇文章結合項目源碼一起閱讀,相信對你的收獲絕對不小。另附上項目、在線 DEMO 地址:

18. 多個組件的組合和拆分

組合和拆分的技術點相對來說比較多,共有以下 4 個:

  • 選中區域
  • 組合后的移動、旋轉
  • 組合后的放大縮小
  • 拆分后子組件樣式的恢復

選中區域

在將多個組件組合之前,需要先選中它們。利用鼠標事件可以很方便的將選中區域展示出來:

  1. mousedown 記錄起點坐標
  2. mousemove 將當前坐標和起點坐標進行計算得出移動區域
  3. 如果按下鼠標后往左上方移動,類似于這種操作則需要將當前坐標設為起點坐標,再計算出移動區域
// 獲取編輯器的位移信息
const rectInfo = this.editor.getBoundingClientRect()
this.editorX = rectInfo.x
this.editorY = rectInfo.y

const startX = e.clientX
const startY = e.clientY
this.start.x = startX - this.editorX
this.start.y = startY - this.editorY
// 展示選中區域
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 事件觸發時,需要對選中區域內的所有組件的位移大小信息進行計算,得出一個能包含區域內所有組件的最小區域。這個效果如下圖所示:

這個計算過程的代碼:

createGroup() {
  // 獲取選中區域的組件數據
  const areaData = this.getSelectArea()
  if (areaData.length <= 1) {
      this.hideArea()
      return
  }

  // 根據選中區域和區域中每個組件的位移信息來創建 Group 組件
  // 要遍歷選擇區域的每個組件,獲取它們的 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
    
  // 設置選中區域位移大小信息和區域內的組件數據
  this.$store.commit('setAreaData', {
      style: {
          left,
          top,
          width: this.width,
          height: this.height,
      },
      components: areaData,
  })
},
        
getSelectArea() {
    const result = []
    // 區域起點坐標
    const { x, y } = this.start
    // 計算所有的組件數據,判斷是否在選中區域內
    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)
        }
    })
    
    // 返回在選中區域內的所有組件
    return result
}

簡單描述一下這段代碼的處理邏輯:

  1. 利用 getBoundingClientRect() 瀏覽器 API 獲取每個組件相對于瀏覽器視口四個方向上的信息,也就是 left top right bottom。
  2. 對比每個組件的這四個信息,取得選中區域的最左、最上、最右、最下四個方向的數值,從而得出一個能包含區域內所有組件的最小區域。
  3. 如果選中區域內已經有一個 Group 組合組件,則需要對它里面的子組件進行計算,而不是對組合組件進行計算。

組合后的移動、旋轉

為了方便將多個組件一起進行移動、旋轉、放大縮小等操作,我新創建了一個 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,說明已經計算過一次了。不需要再次計算
            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 組件的作用就是將區域內的組件放到它下面,成為子組件。并且在創建 Group 組件時,獲取每個子組件在 Group 組件內的相對位移和相對大?。?/p>

created() {
    const parentStyle = this.element.style
    this.propValue.forEach(component => {
        // component.groupStyle 的 top left 是相對于 group 組件的位置
        // 如果已存在 component.groupStyle,說明已經計算過一次了。不需要再次計算
        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 等屬性轉成以 % 結尾的相對數值。

為什么不使用絕對數值?

如果使用絕對數值,那么在移動 Group 組件時,除了對 Group 組件的屬性進行計算外,還需要對它的每個子組件進行計算。并且 Group 包含子組件太多的話,在進行移動、放大縮小時,計算量會非常大,有可能會造成頁面卡頓。如果改成相對數值,則只需要在 Group 創建時計算一次。然后在 Group 組件進行移動、旋轉時也不用管 Group 的子組件,只對它自己計算即可。

組合后的放大縮小

組合后的放大縮小是個大問題,主要是因為有旋轉角度的存在。首先來看一下各個子組件沒旋轉時的放大縮?。?/p>

從動圖可以看出,效果非常完美。各個子組件的大小是跟隨 Group 組件的大小而改變的。

現在試著給子組件加上旋轉角度,再看一下效果:

為什么會出現這個問題?

主要是因為一個組件無論旋不旋轉,它的 top left 屬性都是不變的。這樣就會有一個問題,雖然實際上組件的 top left width height 屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的組件:一個沒旋轉,一個旋轉了 45 度。

可以看出來旋轉后按鈕的 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 組件上的相對數值。這可以通過 getBoundingClientRect() API 實現。只要維持外觀上的各個屬性占比不變,這樣 Group 組件在放大縮小時,再通過旋轉角度,利用旋轉矩陣的知識(這一點在第二篇有詳細描述)獲取它未旋轉前的 top left width height 屬性。這樣就可以做到子組件動態調整了。

但是這有個問題,通過 getBoundingClientRect() API 只能獲取組件外觀上的 top left right bottom width height 屬性。再加上一個角度,參數還是不夠,所以無法計算出組件實際的 top left width height 屬性。

就像上面的這張圖,只知道原點 O(x,y) w h 和旋轉角度,無法算出按鈕的寬高。

一個可行的解決方案

這是無意中發現的,我在對 Group 組件進行放大縮小時,發現只要保持 Group 組件的寬高比例,子組件就能做到根據比例放大縮小。那么現在問題就轉變成了如何讓 Group 組件放大縮小時保持寬高比例。我在網上找到了這一篇文章,它詳細描述了一個旋轉組件如何保持寬高比來進行放大縮小,并配有源碼示例。

現在我嘗試簡單描述一下如何保持寬高比對一個旋轉組件進行放大縮?。ńㄗh還是看看原文)。下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。

第一步,算出組件寬高比,以及按下鼠標時通過組件的坐標(無論旋轉多少度,組件的 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),
    }
}

由于組件處于旋轉狀態,即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉的情況下對其進行計算。

第四步,根據已知的旋轉角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標 currentPosition 在未旋轉時的坐標 newTopLeftPoint。同時也能根據已知的旋轉角度、新的組件中心點、對稱點算出組件對稱點 sPoint 在未旋轉時的坐標 newBottomRightPoint。

對應的計算公式如下:

/**
 * 計算根據圓心旋轉后的點的坐標
 * @param   {Object}  point  旋轉前的點坐標
 * @param   {Object}  center 旋轉中心
 * @param   {Number}  rotate 旋轉的角度
 * @return  {Object}         旋轉后的坐標
 * https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋轉公式:
     *  點a(x, y)
     *  旋轉中心c(x, y)
     *  旋轉后點n(x, y)
     *  旋轉角度θ                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,
    }
}

上面的公式涉及到線性代數中旋轉矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:

通過以上幾個計算值,就可以得到組件新的位移值 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)
    }
}

現在再來看一下旋轉后的放大縮?。?/p>

第五步,由于我們現在需要的是鎖定寬高比來進行放大縮小,所以需要重新計算拉伸后的圖形的左上角坐標。

這里先確定好幾個形狀的命名:

  • 原圖形:  紅色部分
  • 新圖形:  藍色部分
  • 修正圖形: 綠色部分,即加上寬高比鎖定規則的修正圖形

在第四步中算出組件未旋轉前的 newTopLeftPoint newBottomRightPoint newWidth newHeight 后,需要根據寬高比 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
}

由于現在求的未旋轉前的坐標是以沒按比例縮減寬高前的坐標來計算的,所以縮減寬高后,需要按照原來的中心點旋轉回去,獲得縮減寬高并旋轉后對應的坐標。然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉前的坐標。

經過修改后的完整代碼如下:

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
        }

        // 由于現在求的未旋轉前的坐標是以沒按比例縮減寬高前的坐標來計算的
        // 所以縮減寬高后,需要按照原來的中心點旋轉回去,獲得縮減寬高并旋轉后對應的坐標
        // 然后以這個坐標和對稱點獲得新的中心點,并重新計算未旋轉前的坐標
        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 組件有旋轉的子組件時,才需要保持寬高比進行放大縮小。所以在創建 Group 組件時可以判斷一下子組件是否有旋轉角度。如果沒有,就不需要保持寬度比進行放大縮小。

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 組件進行拆分并恢復各個子組件的樣式。保證拆分后的子組件在外觀上的屬性不變。

計算代碼如下:

// 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 = {}
}

這段代碼的處理邏輯為:

  1. 遍歷 Group 的子組件并恢復它們的樣式
  2. 利用 getBoundingClientRect() API 獲取子組件相對于瀏覽器視口的 left top width height 屬性。
  3. 利用這四個屬性計算出子組件的中心點坐標。
  4. 由于子組件的 width height 屬性是相對于 Group 組件的,所以將它們的百分比值和 Group 相乘得出具體數值。
  5. 再用中心點 center(x, y) 減去子組件寬高的一半得出它的 left top 屬性。

至此,組合和拆分就講解完了。

19. 文本組件

文本組件 VText 之前就已經實現過了,但不完美。例如無法對文字進行選中?,F在我對它進行了重寫,讓它支持選中功能。

<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 || '&nbsp;'
            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. 矩形組件

矩形組件其實就是一個內嵌 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 和墨刀有這個功能,于是我順便實現了。鎖定組件的具體需求為:不能移動、放大縮小、旋轉、復制、粘貼等,只能進行解鎖操作。

它的實現原理也不難:

  1. 在自定義組件上加一個 isLock 屬性,表示是否鎖定組件。
  2. 在點擊組件時,根據 isLock 是否為 true 來隱藏組件上的八個點和旋轉圖標。
  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. 快捷鍵

支持快捷鍵主要是為了提升開發效率,用鼠標點點點畢竟沒有按鍵盤快。目前快捷鍵支持的功能如下:

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,保存的快照數據越多占用的內存就越多。對此有兩個解決方案:

  1. 限制快照步數,例如只能保存 50 步的快照數據。
  2. 保存快照只保存差異部分。

現在詳細描述一下第二個解決方案。

假設依次往畫布上添加 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 }],
]
  1. snapshotData[0] 類型為 add,將組件 a 添加到 componentData 中,此時 componentData[a]
  2. 依次類推 [a, b]
  3. [a, b, c]
  4. [a, b, c, d]

如果這時執行 redo 重做操作,快照索引 snapshotIndex 變為 4。對應的快照數據類型為 type: 'remove', 移除組件 c。則數組數據為 [a, b, d]。

這種方法其實就是時間換空間,雖然每一次保存的快照數據只有一項,但每次都得遍歷一遍所有的快照數據。兩種方法都不完美,要使用哪種取決于你,目前我仍在使用第一種方法。

總結

從造輪子的角度來看,這是我目前造的第四個比較滿意的輪子,其他三個為:

造輪子是一個很好的提升自己技術水平的方法,但造輪子一定要造有意義、有難度的輪子,并且同類型的輪子只造一個。造完輪子后,還需要寫總結,最好輸出成文章分享出去。

參考資料

閱讀 4k

公眾號:前端編程技術分享

5.5k 聲望
10.3k 粉絲
0 條評論

公眾號:前端編程技術分享

5.5k 聲望
10.3k 粉絲
宣傳欄
一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>