可视化拖拽组件库一些技术要点原理分析(三)(三)

简介: 可视化拖拽组件库一些技术要点原理分析(三)(三)

拆分后子组件样式的恢复

将多个组件组合在一起只是第一步,第二步是将 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 获取子组件相对于浏览器视口的 lefttopwidthheight 属性。
  3. 利用这四个属性计算出子组件的中心点坐标。
  4. 由于子组件的 widthheight 属性是相对于 Group 组件的,所以将它们的百分比值和 Group 相乘得出具体数值。
  5. 再用中心点 center(x, y) 减去子组件宽高的一半得出它的 lefttop 属性。

至此,组合和拆分就讲解完了。

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]

这种方法其实就是时间换空间,虽然每一次保存的快照数据只有一项,但每次都得遍历一遍所有的快照数据。两种方法都不完美,要使用哪种取决于你,目前我仍在使用第一种方法。

总结

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

造轮子是一个很好的提升自己技术水平的方法,但造轮子一定要造有意义、有难度的轮子,并且同类型的轮子只造一个。造完轮子后,还需要写总结,最好输出成文章分享出去。

参考资料

目录
相关文章
|
4月前
|
SQL 前端开发 JavaScript
kettle开发-超好用自定义数据处理组件
kettle开发-超好用自定义数据处理组件
55 0
|
9月前
|
数据可视化 API
可视化拖拽组件库一些技术要点原理分析(三)(二)
可视化拖拽组件库一些技术要点原理分析(三)(二)
69 0
|
9月前
|
数据可视化 JavaScript
可视化拖拽组件库一些技术要点原理分析(二)(上)
可视化拖拽组件库一些技术要点原理分析(二)
51 1
|
9月前
|
数据可视化 索引
可视化拖拽组件库一些技术要点原理分析(二)
可视化拖拽组件库一些技术要点原理分析(二)
73 0
|
8月前
|
JSON 数据格式
通过一个具体的例子,深入了解 SAP UI5 控件数据双向绑定的工作原理和问题排查方法试读版
通过一个具体的例子,深入了解 SAP UI5 控件数据双向绑定的工作原理和问题排查方法试读版
33 0
|
9月前
|
数据可视化 前端开发 JavaScript
可视化拖拽组件库一些技术要点原理分析(四)(下)
可视化拖拽组件库一些技术要点原理分析(四)(下)
46 0
|
9月前
|
数据可视化 API
可视化拖拽组件库一些技术要点原理分析(三)(一)
可视化拖拽组件库一些技术要点原理分析(三)
45 0
|
9月前
|
数据可视化 JavaScript
可视化拖拽组件库一些技术要点原理分析(四)(上)
可视化拖拽组件库一些技术要点原理分析(四)
53 0
可视化拖拽组件库一些技术要点原理分析(四)(上)
|
9月前
|
JSON 数据可视化 前端开发
可视化拖拽组件库一些技术要点原理分析(二)(下)
可视化拖拽组件库一些技术要点原理分析(二)(下)
78 0
|
9月前
|
数据可视化 JavaScript 前端开发
可视化拖拽组件库一些技术要点原理分析(一)
可视化拖拽组件库一些技术要点原理分析(一)
146 0