前言
在去年闲暇时间开发了一个可视化的页面编辑器,这次看到掘金有项目复盘的活动,正好可以拿出来写篇文章,和大家分享下。不知道还能不能赶上活动了~
在开始文章之前你可以先体验下:
在线预览
GitHub 地址
编辑器主要功能
- 元素自由拖拽,放大,缩小,旋转
- 可添加图片,文本,矩形,背景。多种编辑功能(字体,背景,大小,边距等)
- 组件自动吸附,实时参考线(组件可以和画布,自定义参考线以及其他组件进行自动吸附对齐,并显示实时参考线,拖动过程中按下 alt 键可暂时关闭)
- 标尺,参考线,可自定义参考线(在标尺上点击即可生成参开线,可拖动参考线更改位置,双击删除参考线)
- 撤销,重做(支持快捷键,可配置撤销的步数)
- 组件复制,粘贴,锁定,隐藏等
- ctrl + 拖动组件可快速复制组件
- 右键菜单,菜单可配置,可针对组件当前状态灵活生成(即不同的组件可产生不同的菜单)
- 图层面板,可拖拽更改组件图层,可重命名,可在图层面板快速锁定,删除,隐藏组件
- 同时选中多组件(按 ctrl + 左键),可进行多组件对齐
- 数据备份,通过 indexDB 数据库保存在本地(可自动备份,手动备份),并可从备份中恢复数据
- 一键生成 h5 代码
- 编辑画布大小
- 多种快捷键
- 设置中心,可设置撤销功能,备份功能等
- 可通过插件系统二次开发
由于里面的细节比较多,肯定不能将所有点都讲到,我就挑几个主要的写写,有些可能写的比较简略,具体的实现可以看 GitHub 上的源码。
整体架构
这种编辑器一般都是分为 3 个区域,左中右,在左边添加组件,中间操作,右侧可以编辑组件的一些属性,我这个是参考易企秀的设计,中部和右部有一个快捷操作栏,有一些常用的设置。
这些不同的区域对应不同的功能,那么在代码里我们也要将这些不同的功能区域分开:
<!-- index.vue -->
<template>
<div class="poster-editor" :class="{ 'init-loading': initLoading }">
<div class="base">
<!-- 左侧添加组件栏 -->
<left-side />
<!-- 主要操作区域 -->
<main-component ref="main" />
<!-- 常用功能栏 -->
<extend-side-bar />
<!-- 组件编辑区域 -->
<control-component />
</div>
<!-- 图层面板 -->
<transition name="el-zoom-in-top">
<layer-panel v-show="layerPanelOpened" />
</transition>
</div>
</template>
然后还要有数据,这数据包含了画布属性,组件属性,当前编辑器状态等等,存在 vuex 中:
const state = {
activityId: '',
pageConfigId: '',
pageTitle: '',
canvasSize: {
width: 338,
height: 600
},
canvasPosition: {
top: null,
left: null
},
background: null,
posterItems: [], // 组件列表
activeItems: [], // 当前选中的组件
assistWidgets: [], // 辅助组件
layerPanelOpened: true, // 是否打开图层面板
referenceLineOpened: true, // 是否打开参考线
copiedWidgets: null, // 当前复制的组件 WidgetItem[]
referenceLine: {
// 参考线,用户定义的参考线
row: [],
col: []
},
matchedLine: null, // 匹配到的参考线 {row:[],col:[]}
mainPanelScrollY: 0,
isUnsavedState: false // 是否处于未保存状态
}
这里最主要的就是posterItems
属性,是保存当前所有组件的数组。添加组件就是往这个数组中 push 数据,然后遍历posterItems
将组件放在画布上,进而可以编辑这个组件。
组件实现
因为一共有很多种组件,并且这些组件都有一些相同属性,比如位置大小信息,是否锁定,是否隐藏等等,这些相同的属性不可能每个组件都写一遍,所以要就要实现一个基础的组件,这个基础组件包含了所有组件通用的属性,然后其他组件就通过这个组件扩展,我这里通过 class 的方式实现:
const defaultWidgetConfig = () => {
return {
id: '', // 组件id
type: '', // 类型
typeLabel: '', // 类型标签
componentName: '', // 动态component的name
icon: '', // 图标class
wState: {}, // 组件内部状态数据,样式属性等信息
dragInfo: { w: 100, h: 100, x: 0, y: 0, rotateZ: 0 }, // 组件的位置、大小、旋转角度
rename: '', // typeLabel重命名
lock: false, // 是否处于锁定状态
visible: true, // 是否可见
initHook: null, // Function 组件初始化时候(created)执行
layerPanelVisible: true, // 是否在图片面板中可见
replicable: true, // 是否可复制
isCopied: false, // 是否是复制的组件(通过复制操作获得的组件)
removable: true, // 是否可删除
couldAddToActive: true, // 是否可被添加进activeItems
componentState: null // Function 复制组件时有效,返回结果为为复制时原组件内部的data;componentState.count为复制的次数
/**
* @property {Int} _copyCount 复制的次数
* @property {String} _copyFrom 复制来源 command | drag
* @property {Boolean} _isBackup 是否是通过备份恢复的组件
* @property {Int} _widgetCountLimit 该组件的数量限制
* @property {Int} _sort 组件图层排序
*/
}
}
// 组件父类
export default class Widget {
constructor(config) {
const item = _merge(defaultWidgetConfig(), config, {
id: uniqueId(config.typeLabel + '-')
})
// this._config = item
Object.keys(item).forEach((key) => {
this[key] = item[key]
})
}
// 组件mixin
static widgetMixin(options) {
// ...一会讲
}
}
defaultWidgetConfig
是组件的配置项,Widget
其实就是初始化这些配置,然后其他组件继承Widget
就可以了,比如我们要实现一个文本组件:
// 文本Widget
export default class TextWidget extends Widget {
constructor(config) {
config = _merge(
{
type: 'text',
typeLabel: '文本',
componentName: 'text-widget',
icon: 'icon-text',
lock: false,
visible: true,
wState: {
text: '双击编辑文本',
style: {
margin: '10px',
wordBreak: 'break-all',
color: '#000',
textAlign: 'center',
fontSize: '14px', // px
padding: 0, // px
borderColor: '#000',
borderWidth: 0, // px
borderStyle: 'solid',
lineHeight: '100%', // %
letterSpacing: 0, // %
backgroundColor: '',
fontWeight: '',
fontStyle: '',
textDecoration: ''
}
}
},
config
)
super(config)
}
}
这个构造函数里面就可以初始化一些配置,然后使用时就 new 一个,然后添加到posterItems
中,简化后大概是这样的:
// 添加文本组件
store.dispatch('poster/addItem', new TextWidget())
const actions = {
addItem(state, item) {
if (item instanceof Widget) {
state.posterItems.push(item)
}
}
}
添加完之后,按照上文所说,编辑器中部的操作区域是遍历这个posterItems
的:
<component
v-for="item in posterItems"
:key="item.id"
:item="item"
:is="item.componentName"
/>
这里是通过componentName
来调用不同的组件,刚才的TextWidget
已经配置了componentName
是text-widget
,现在我们就来实现这个组件:
<!-- textWidget.vue -->
<template>
<div class="text-widget">demo</div>
</template>
<script>
import { TextWidget } from 'poster/widgetConstructor'
export default {
mixins: [TextWidget.widgetMixin()],
data() {
return {}
}
}
</script>
<style lang="scss" scoped></style>
现在添加组件后,你就能在画布上看到demo
了,这只是个示例,更加详细的内容可以看 GitHub 的源码。
注意这里引入了一个 mixin,这个TextWidget.widgetMixin
其实就是Widget
上的:
export default class Widget {
constructor(config) {
// ...
}
// 组件mixin
static widgetMixin(options) {
// ...
}
}
就是一些组件公共的逻辑,其实这里用高阶组件的方式比较好,用 mixin 的话需要每个组件都要写一遍,比较繁琐,只是当时没想清楚。这个 mixin 一会就会用到,用到再说,这里有个印象即可。
拖拽缩放功能
这个是直接用了一个 Vue 组件:vue-draggable-resizable,直接调用这个组件就行:
<!-- textWidget.vue -->
<template>
<vue-draggable-resizable>
<div class="text-widget">demo</div>
</vue-draggable-resizable>
</template>
这样虽然是可行的,但是有个问题就是我们不仅有文本组件,还有图片,矩形、背景,以后可能还要添加其他组件,而且这个拖动不是套一下就可以的,我们还要写其他的很多逻辑,比如拖动时将 x、y 轴的数据实时更新到组件的属性中,还要有吸附对齐等功能,肯定不能每个组件都去写一遍这些东西,这个时候其实可以把“拖拽”和“组件”拆开,“拖拽”作为一个容器,然后里面嵌套“组件”
<vue-draggable-resizable v-for="item in posterItems" :key="item.id">
<component
:is="item.componentName"
ref="widget"
:item="item"
:is-active="isActive"
v-on="$listeners"
@draggableChange="draggable = $event"
/>
</vue-draggable-resizable>
这样其实就是多了一层,把拖拽相关的逻辑都写在拖拽容器里,我们只需要实现内层“组件”的逻辑就可以了。
设置组件属性
画布上有了组件后,还要可以在右侧的编辑区域编辑组件,比如字号,背景,边框等等,刚才的TextWidget
里面有一个style
属性,字号什么的就是更改这个属性。
那么我们要做的就是点击这个组件时,将这个组件置为active
状态,然后右侧的区域就显示对应的属性编辑器,不同组件的设置项肯定也不一样,比如TextWidget
需要字号,字体颜色,但是如果我们要做一个矩形的组件,设置项肯定和TextWidget
不同,那么我们就需要单独实现各个组件的属性编辑器,然后判断当前active
状态的组件的类型,根据不同的类型调用不同的属性编辑器。
复制组件
编辑器最必不可少的一个功能就是复制组件,复制组件分为“复制”、“粘贴”两个步骤,复制时就把当前这个组件的配置全部保存下来,,粘贴时就把这个配置拿出来,通过这个配置创建一个组件,添加到posterItems
中去,下面是简化后的代码:
// 复制组件
const mutations = {
[MTS.COPY_WIDGET](state, item) {
const config = _.cloneDeep(item)
state.copiedWidgets = config
}
}
export default class CopiedWidget extends Widget {
constructor(config) {
config._copyCount += 1
const configCopy = Object.assign({}, _.cloneDeep(config), {
typeLabel: config.typeLabel + '-copy',
isCopied: true
})
super(configCopy)
}
}
然后粘贴的时候只需要state.posterItems.push(new CopiedWidget(state.copiedWidgets))
,就可以了。
自动吸附
我这里说一下我的实现思路,代码就不贴了,因为比较多,感兴趣的小伙伴可以查看源码。
其实思路很简单,组件在X轴方向上对应“上”“中”“下”三条线,在Y轴上对应“左”“中”“右”三条线:
假设此时有A和B两个组件,现在在拖动B组件,拖动过程中,我们需要实时监测B的左中右三条边和A的是否靠的足够近,就要拿B的左边和A的左中右分别对比,再拿B的中边和A的左中右对比,再拿B的右边和A的左中右对比,总共对比三轮,中间哪一次对比发现两条边的距离达到预设值了,比如说B的左边和A的右边相差了5像素,这时候就可以手动更改B的X轴坐标将B和A对齐:
对应的,上中下三条边也是分别对比,然后匹配到的话就修改B的Y轴坐标。
就是这个思路,实际情况可能比较复杂点,因为不可能只有两个组件,而且除了要组件之间对齐,还要支持自定义参考线,还要和画布的边对齐,感兴趣的同学可以直接看Github的源码。
总结
这篇文章有些地方写的可能比较简略,因为不是一个教程类型的,是对以前的项目做个分享,所以写的比较简略,如果你有什么想法,或者想知道哪个功能点的具体实现细节的,欢迎和我交流。
如果你觉得这个项目还不错的话,欢迎点个赞,感谢~
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!