8. 组件属性设置
每个组件都有一些通用属性和独有的属性,我们需要提供一个能显示和修改属性的地方。
// 每个组件数据大概是这样 { component: 'v-text', // 组件名称,需要提前注册到 Vue label: '文字', // 左侧组件列表中显示的名字 propValue: '文字', // 组件所使用的值 icon: 'el-icon-edit', // 左侧组件列表中显示的名字 animations: [], // 动画列表 events: {}, // 事件列表 style: { // 组件样式 width: 200, height: 33, fontSize: 14, fontWeight: 500, lineHeight: '', letterSpacing: 0, textAlign: '', color: '', }, }
我定义了一个 AttrList
组件,用于显示每个组件的属性。
<template> <div class="attr-list"> <el-form> <el-form-item v-for="(key, index) in styleKeys" :key="index" :label="map[key]"> <el-color-picker v-if="key == 'borderColor'" v-model="curComponent.style[key]"></el-color-picker> <el-color-picker v-else-if="key == 'color'" v-model="curComponent.style[key]"></el-color-picker> <el-color-picker v-else-if="key == 'backgroundColor'" v-model="curComponent.style[key]"></el-color-picker> <el-select v-else-if="key == 'textAlign'" v-model="curComponent.style[key]"> <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" ></el-option> </el-select> <el-input type="number" v-else v-model="curComponent.style[key]" /> </el-form-item> <el-form-item label="内容" v-if="curComponent && curComponent.propValue && !excludes.includes(curComponent.component)"> <el-input type="textarea" v-model="curComponent.propValue" /> </el-form-item> </el-form> </div> </template>
代码逻辑很简单,就是遍历组件的 style
对象,将每一个属性遍历出来。并且需要根据具体的属性用不同的组件显示出来,例如颜色属性,需要用颜色选择器显示;数值类的属性需要用 type=number
的 input 组件显示等等。
为了方便用户修改属性值,我使用 v-model
将组件和值绑定在一起。
9. 预览、保存代码
预览和编辑的渲染原理是一样的,区别是不需要编辑功能。所以只需要将原先渲染组件的代码稍微改一下就可以了。
<!--页面组件列表展示--> <Shape v-for="(item, index) in componentData" :defaultStyle="item.style" :style="getShapeStyle(item.style, index)" :key="item.id" :active="item === curComponent" :element="item" :zIndex="index" > <component class="component" :is="item.component" :style="getComponentStyle(item.style)" :propValue="item.propValue" /> </Shape>
经过刚才的介绍,我们知道 Shape
组件具备了拖拽、放大缩小的功能。现在只需要将 Shape
组件去掉,外面改成套一个普通的 DIV 就可以了(其实不用这个 DIV 也行,但为了绑定事件这个功能,所以需要加上)。
<!--页面组件列表展示--> <div v-for="(item, index) in componentData" :key="item.id"> <component class="component" :is="item.component" :style="getComponentStyle(item.style)" :propValue="item.propValue" /> </div>
保存代码的功能也特别简单,只需要保存画布上的数据 componentData
即可。保存有两种选择:
- 保存到服务器
- 本地保存
在 DEMO 上我使用的 localStorage
保存在本地。
10. 绑定事件
每个组件有一个 events
对象,用于存储绑定的事件。目前我只定义了两个事件:
- alert 事件
- redirect 事件
// 编辑器自定义事件 const events = { redirect(url) { if (url) { window.location.href = url } }, alert(msg) { if (msg) { alert(msg) } }, } const mixins = { methods: events, } const eventList = [ { key: 'redirect', label: '跳转事件', event: events.redirect, param: '', }, { key: 'alert', label: 'alert 事件', event: events.alert, param: '', }, ] export { mixins, events, eventList, }
- 不过不能在编辑的时候触发,可以在预览的时候触发。
添加事件
通过 v-for
指令将事件列表渲染出来:
<el-tabs v-model="eventActiveName"> <el-tab-pane v-for="item in eventList" :key="item.key" :label="item.label" :name="item.key" style="padding: 0 20px"> <el-input v-if="item.key == 'redirect'" v-model="item.param" type="textarea" placeholder="请输入完整的 URL" /> <el-input v-if="item.key == 'alert'" v-model="item.param" type="textarea" placeholder="请输入要 alert 的内容" /> <el-button style="margin-top: 20px;" @click="addEvent(item.key, item.param)">确定</el-button> </el-tab-pane> </el-tabs>
选中事件时将事件添加到组件的 events
对象。
触发事件
预览或真正渲染页面时,也需要在每个组件外面套一层 DIV,这样就可以在 DIV 上绑定一个点击事件,点击时触发我们刚才添加的事件。
<template> <div @click="handleClick"> <component class="conponent" :is="config.component" :style="getStyle(config.style)" :propValue="config.propValue" /> </div> </template>
handleClick() { const events = this.config.events // 循环触发绑定的事件 Object.keys(events).forEach(event => { this[event](events[event]) }) }
11. 绑定动画
动画和事件的原理是一样的,先将所有的动画通过 v-for
指令渲染出来,然后点击动画将对应的动画添加到组件的 animations
数组里。同事件一样,执行的时候也是遍历组件所有的动画并执行。
为了方便,我们使用了 animate.css 动画库。
// main.js import '@/styles/animate.css'
现在我们提前定义好所有的动画数据:
export default [ { label: '进入', children: [ { label: '渐显', value: 'fadeIn' }, { label: '向右进入', value: 'fadeInLeft' }, { label: '向左进入', value: 'fadeInRight' }, { label: '向上进入', value: 'fadeInUp' }, { label: '向下进入', value: 'fadeInDown' }, { label: '向右长距进入', value: 'fadeInLeftBig' }, { label: '向左长距进入', value: 'fadeInRightBig' }, { label: '向上长距进入', value: 'fadeInUpBig' }, { label: '向下长距进入', value: 'fadeInDownBig' }, { label: '旋转进入', value: 'rotateIn' }, { label: '左顺时针旋转', value: 'rotateInDownLeft' }, { label: '右逆时针旋转', value: 'rotateInDownRight' }, { label: '左逆时针旋转', value: 'rotateInUpLeft' }, { label: '右逆时针旋转', value: 'rotateInUpRight' }, { label: '弹入', value: 'bounceIn' }, { label: '向右弹入', value: 'bounceInLeft' }, { label: '向左弹入', value: 'bounceInRight' }, { label: '向上弹入', value: 'bounceInUp' }, { label: '向下弹入', value: 'bounceInDown' }, { label: '光速从右进入', value: 'lightSpeedInRight' }, { label: '光速从左进入', value: 'lightSpeedInLeft' }, { label: '光速从右退出', value: 'lightSpeedOutRight' }, { label: '光速从左退出', value: 'lightSpeedOutLeft' }, { label: 'Y轴旋转', value: 'flip' }, { label: '中心X轴旋转', value: 'flipInX' }, { label: '中心Y轴旋转', value: 'flipInY' }, { label: '左长半径旋转', value: 'rollIn' }, { label: '由小变大进入', value: 'zoomIn' }, { label: '左变大进入', value: 'zoomInLeft' }, { label: '右变大进入', value: 'zoomInRight' }, { label: '向上变大进入', value: 'zoomInUp' }, { label: '向下变大进入', value: 'zoomInDown' }, { label: '向右滑动展开', value: 'slideInLeft' }, { label: '向左滑动展开', value: 'slideInRight' }, { label: '向上滑动展开', value: 'slideInUp' }, { label: '向下滑动展开', value: 'slideInDown' }, ], }, { label: '强调', children: [ { label: '弹跳', value: 'bounce' }, { label: '闪烁', value: 'flash' }, { label: '放大缩小', value: 'pulse' }, { label: '放大缩小弹簧', value: 'rubberBand' }, { label: '左右晃动', value: 'headShake' }, { label: '左右扇形摇摆', value: 'swing' }, { label: '放大晃动缩小', value: 'tada' }, { label: '扇形摇摆', value: 'wobble' }, { label: '左右上下晃动', value: 'jello' }, { label: 'Y轴旋转', value: 'flip' }, ], }, { label: '退出', children: [ { label: '渐隐', value: 'fadeOut' }, { label: '向左退出', value: 'fadeOutLeft' }, { label: '向右退出', value: 'fadeOutRight' }, { label: '向上退出', value: 'fadeOutUp' }, { label: '向下退出', value: 'fadeOutDown' }, { label: '向左长距退出', value: 'fadeOutLeftBig' }, { label: '向右长距退出', value: 'fadeOutRightBig' }, { label: '向上长距退出', value: 'fadeOutUpBig' }, { label: '向下长距退出', value: 'fadeOutDownBig' }, { label: '旋转退出', value: 'rotateOut' }, { label: '左顺时针旋转', value: 'rotateOutDownLeft' }, { label: '右逆时针旋转', value: 'rotateOutDownRight' }, { label: '左逆时针旋转', value: 'rotateOutUpLeft' }, { label: '右逆时针旋转', value: 'rotateOutUpRight' }, { label: '弹出', value: 'bounceOut' }, { label: '向左弹出', value: 'bounceOutLeft' }, { label: '向右弹出', value: 'bounceOutRight' }, { label: '向上弹出', value: 'bounceOutUp' }, { label: '向下弹出', value: 'bounceOutDown' }, { label: '中心X轴旋转', value: 'flipOutX' }, { label: '中心Y轴旋转', value: 'flipOutY' }, { label: '左长半径旋转', value: 'rollOut' }, { label: '由小变大退出', value: 'zoomOut' }, { label: '左变大退出', value: 'zoomOutLeft' }, { label: '右变大退出', value: 'zoomOutRight' }, { label: '向上变大退出', value: 'zoomOutUp' }, { label: '向下变大退出', value: 'zoomOutDown' }, { label: '向左滑动收起', value: 'slideOutLeft' }, { label: '向右滑动收起', value: 'slideOutRight' }, { label: '向上滑动收起', value: 'slideOutUp' }, { label: '向下滑动收起', value: 'slideOutDown' }, ], }, ]
然后用 v-for
指令渲染出来动画列表。
添加动画
<el-tabs v-model="animationActiveName"> <el-tab-pane v-for="item in animationClassData" :key="item.label" :label="item.label" :name="item.label"> <el-scrollbar class="animate-container"> <div class="animate" v-for="(animate, index) in item.children" :key="index" @mouseover="hoverPreviewAnimate = animate.value" @click="addAnimation(animate)" > <div :class="[hoverPreviewAnimate === animate.value && animate.value + ' animated']"> {{ animate.label }} </div> </div> </el-scrollbar> </el-tab-pane> </el-tabs>
点击动画将调用 addAnimation(animate)
将动画添加到组件的 animations
数组。
触发动画
运行动画的代码:
export default async function runAnimation($el, animations = []) { const play = (animation) => new Promise(resolve => { $el.classList.add(animation.value, 'animated') const removeAnimation = () => { $el.removeEventListener('animationend', removeAnimation) $el.removeEventListener('animationcancel', removeAnimation) $el.classList.remove(animation.value, 'animated') resolve() } $el.addEventListener('animationend', removeAnimation) $el.addEventListener('animationcancel', removeAnimation) }) for (let i = 0, len = animations.length; i < len; i++) { await play(animations[i]) } }
运行动画需要两个参数:组件对应的 DOM 元素(在组件使用 this.$el
获取)和它的动画数据 animations
。并且需要监听 animationend
事件和 animationcancel
事件:一个是动画结束时触发,一个是动画意外终止时触发。
利用这一点再配合 Promise
一起使用,就可以逐个运行组件的每个动画了。
12. 导入 PSD
由于时间关系,这个功能我还没做。现在简单的描述一下怎么做这个功能。那就是使用 psd.js 库,它可以解析 PSD 文件。
使用 psd
库解析 PSD 文件得出的数据如下:
{ children: [ { type: 'group', visible: false, opacity: 1, blendingMode: 'normal', name: 'Version D', left: 0, right: 900, top: 0, bottom: 600, height: 600, width: 900, children: [ { type: 'layer', visible: true, opacity: 1, blendingMode: 'normal', name: 'Make a change and save.', left: 275, right: 636, top: 435, bottom: 466, height: 31, width: 361, mask: {}, text: { value: 'Make a change and save.', font: { name: 'HelveticaNeue-Light', sizes: [ 33 ], colors: [ [ 85, 96, 110, 255 ] ], alignment: [ 'center' ] }, left: 0, top: 0, right: 0, bottom: 0, transform: { xx: 1, xy: 0, yx: 0, yy: 1, tx: 456, ty: 459 } }, image: {} } ] } ], document: { width: 900, height: 600, resources: { layerComps: [ { id: 692243163, name: 'Version A', capturedInfo: 1 }, { id: 725235304, name: 'Version B', capturedInfo: 1 }, { id: 730932877, name: 'Version C', capturedInfo: 1 } ], guides: [], slices: [] } } }
从以上代码可以发现,这些数据和 css 非常像。根据这一点,只需要写一个转换函数,将这些数据转换成我们组件所需的数据,就能实现 PSD 文件转成渲染组件的功能。目前 quark-h5 和 luban-h5 都是这样实现的 PSD 转换功能。
13. 手机模式
由于画布是可以调整大小的,我们可以使用 iphone6 的分辨率来开发手机页面。
这样开发出来的页面也可以在手机下正常浏览,但可能会有样式偏差。因为我自定义的三个组件是没有做适配的,如果你需要开发手机页面,那自定义组件必须使用移动端的 UI 组件库。或者自己开发移动端专用的自定义组件。
总结
由于 DEMO 的代码比较多,所以在讲解每一个功能点时,我只把关键代码贴上来。所以大家会发现 DEMO 的源码和我贴上来的代码会有些区别,请不必在意。
另外,DEMO 的样式也比较简陋,主要是最近事情比较多,没太多时间写好看点,请见谅。