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

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

拆分后子组件样式的恢复

将多个组件组合在一起只是第一步,第二步是将 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]

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

总结

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

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

参考资料

目录
相关文章
|
3月前
|
JavaScript
从零开始写一套广告组件【一】搭建基础框架并配置UI组件库
其实这个从零有点歧义,因为本质上是要基于`tdesign-vue-next`来进行二次封装为一套广告UI组件库,现在让我们在一起快乐的搭建自己的广告UI库之前,先对以下内容做出共识:
97 0
从零开始写一套广告组件【一】搭建基础框架并配置UI组件库
|
4月前
|
C# 开发者 数据处理
WPF开发者必备秘籍:深度解析数据网格最佳实践,轻松玩转数据展示与编辑大揭秘!
【8月更文挑战第31天】数据网格控件是WPF应用程序中展示和编辑数据的关键组件,提供排序、筛选等功能,显著提升用户体验。本文探讨WPF中数据网格的最佳实践,通过DevExpress DataGrid示例介绍其集成方法,包括添加引用、定义数据模型及XAML配置。通过遵循数据绑定、性能优化、自定义列等最佳实践,可大幅提升数据处理效率和用户体验。
69 0
|
5月前
|
小程序 前端开发
【微信小程序-原生开发】实用教程22 - 绘制图表(引入 echarts,含图表的懒加载-获取到数据后再渲染图表,多图表加载等技巧)
【微信小程序-原生开发】实用教程22 - 绘制图表(引入 echarts,含图表的懒加载-获取到数据后再渲染图表,多图表加载等技巧)
291 0
|
数据可视化 JavaScript
可视化拖拽组件库一些技术要点原理分析(二)(上)
可视化拖拽组件库一些技术要点原理分析(二)
110 1
|
数据可视化 API
可视化拖拽组件库一些技术要点原理分析(三)(二)
可视化拖拽组件库一些技术要点原理分析(三)(二)
108 0
|
数据可视化 索引
可视化拖拽组件库一些技术要点原理分析(二)
可视化拖拽组件库一些技术要点原理分析(二)
155 0
|
数据可视化 JavaScript
可视化拖拽组件库一些技术要点原理分析(四)(上)
可视化拖拽组件库一些技术要点原理分析(四)
97 0
可视化拖拽组件库一些技术要点原理分析(四)(上)
|
数据可视化 前端开发 JavaScript
可视化拖拽组件库一些技术要点原理分析(四)(下)
可视化拖拽组件库一些技术要点原理分析(四)(下)
78 0
|
JSON 数据可视化 前端开发
可视化拖拽组件库一些技术要点原理分析(二)(下)
可视化拖拽组件库一些技术要点原理分析(二)(下)
112 0
|
数据可视化 API
可视化拖拽组件库一些技术要点原理分析(三)(一)
可视化拖拽组件库一些技术要点原理分析(三)
108 0