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

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

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

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

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

若是你對我以前的兩篇文章不是很瞭解,建議先把這兩篇文章看一遍,再來閱讀此文:vue

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

若是你對於低代碼平臺有興趣,但又不瞭解的話。強烈建議將個人三篇文章結合項目源碼一塊兒閱讀,相信對你的收穫絕對不小。另附上項目、在線 DEMO 地址:github

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

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

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

選中區域

在將多個組件組合以前,須要先選中它們。利用鼠標事件能夠很方便的將選中區域展現出來:數組

  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 事件觸發時,須要對選中區域內的全部組件的位移大小信息進行計算,得出一個能包含區域內全部組件的最小區域。這個效果以下圖所示:瀏覽器

這個計算過程的代碼:app

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 組件內的相對位移和相對大小:

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 的子組件,只對它本身計算便可。

組合後的放大縮小

組合後的放大縮小是個大問題,主要是由於有旋轉角度的存在。首先來看一下各個子組件沒旋轉時的放大縮小:

從動圖能夠看出,效果很是完美。各個子組件的大小是跟隨 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 組件放大縮小時保持寬高比例。我在網上找到了這一篇文章,它詳細描述了一個旋轉組件如何保持寬高比來進行放大縮小,並配有源碼示例。

如今我嘗試簡單描述一下如何保持寬高比對一個旋轉組件進行放大縮小(建議仍是看看原文)。下面是一個已旋轉必定角度的矩形,假設如今拖動它左上方的點進行拉伸。

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

如今再來看一下旋轉後的放大縮小:

第五步,因爲咱們如今須要的是鎖定寬高比來進行放大縮小,因此須要從新計算拉伸後的圖形的左上角座標。

這裏先肯定好幾個形狀的命名:

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

在第四步中算出組件未旋轉前的 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 以前就已經實現過了,但不完美。例如沒法對文字進行選中。如今我對它進行了重寫,讓它支持選中功能。

<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]

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

總結

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

造輪子是一個很好的提高本身技術水平的方法,但造輪子必定要造有意義、有難度的輪子,而且同類型的輪子只造一個。造完輪子後,還須要寫總結,最好輸出成文章分享出去。

參考資料

相關文章
相關標籤/搜索