关于css:可视化拖拽组件库一些技术要点原理分析三

30次阅读

共计 19775 个字符,预计需要花费 50 分钟才能阅读完成。

本文是可视化拖拽系列的第三篇,之前的两篇文章一共对 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 地址:

  • 我的项目地址
  • 在线 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 组件内的绝对位移和绝对大小:

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]

这种办法其实就是工夫换空间,尽管每一次保留的快照数据只有一项,但每次都得遍历一遍所有的快照数据。两种办法都不完满,要应用哪种取决于你,目前我仍在应用第一种办法。

总结

从造轮子的角度来看,这是我目前造的第四个比较满意的轮子,其余三个为:

  • nand2tetris
  • MIT6.828
  • mini-vue

造轮子是一个很好的晋升本人技术水平的办法,但造轮子肯定要造有意义、有难度的轮子,并且同类型的轮子只造一个。造完轮子后,还须要写总结,最好输入成文章分享进来。

参考资料

  • snapping-demo
  • processon
  • 墨刀

正文完
 0