本文主要對如下技術要點進行分析:css
爲了讓本文更加容易理解,我將以上技術要點結合在一塊兒寫了一個可視化拖拽組件庫 DEMO:html
建議結合源碼一塊兒閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。vue
先來看一下頁面的總體結構。git
這一節要講的編輯器其實就是中間的畫布。它的做用是:當從左邊組件列表拖拽出一個組件放到畫布中時,畫布要把這個組件渲染出來。github
這個編輯器的實現思路是:vuex
componentData
維護編輯器中的數據。push()
方法將新的組件數據添加到 componentData
。v-for
指令遍歷 componentData
,將每一個組件逐個渲染到畫布(也可使用 JSX 語法結合 render()
方法代替)。編輯器渲染的核心代碼以下所示:api
<component v-for="item in componentData" :key="item.id" :is="item.component" :style="item.style" :propValue="item.propValue" />
每一個組件數據大概是這樣:數組
{ 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: '', }, }
在遍歷 componentData
組件數據時,主要靠 is
屬性來識別出真正要渲染的是哪一個組件。服務器
例如要渲染的組件數據是 { component: 'v-text' }
,則 <component :is="item.component" />
會被轉換爲 <v-text />
。固然,你這個組件也要提早註冊到 Vue 中。iphone
若是你想了解更多 is
屬性的資料,請查看官方文檔。
原則上使用第三方組件也是能夠的,但建議你最好封裝一下。不論是第三方組件仍是自定義組件,每一個組件所需的屬性可能都不同,因此每一個組件數據能夠暴露出一個屬性 propValue
用於傳遞值。
例如 a 組件只須要一個屬性,你的 propValue
能夠這樣寫:propValue: 'aaa'
。若是須要多個屬性,propValue
則能夠是一個對象:
propValue: { a: 1, b: 'text' }
在這個 DEMO 組件庫中我定義了三個組件。
圖片組件 Picture
:
<template> <div style="overflow: hidden"> <img :src="propValue"> </div> </template> <script> export default { props: { propValue: { type: String, require: true, }, }, } </script>
按鈕組件 VButton
:
<template> <button class="v-button">{{ propValue }}</button> </template> <script> export default { props: { propValue: { type: String, default: '', }, }, } </script>
文本組件 VText
:
<template> <textarea v-if="editMode == 'edit'" :value="propValue" class="text textarea" @input="handleInput" ref="v-text" ></textarea> <div v-else class="text disabled"> <div v-for="(text, index) in propValue.split('\n')" :key="index">{{ text }}</div> </div> </template> <script> import { mapState } from 'vuex' export default { props: { propValue: { type: String, }, element: { type: Object, }, }, computed: mapState([ 'editMode', ]), methods: { handleInput(e) { this.$emit('input', this.element, e.target.value) }, }, } </script>
一個元素若是要設爲可拖拽,必須給它添加一個 draggable
屬性。另外,在將組件列表中的組件拖拽到畫布中,還有兩個事件是起到關鍵做用的:
dragstart
事件,在拖拽剛開始時觸發。它主要用於將拖拽的組件信息傳遞給畫布。drop
事件,在拖拽結束時觸發。主要用於接收拖拽的組件信息。先來看一下左側組件列表的代碼:
<div @dragstart="handleDragStart" class="component-list"> <div v-for="(item, index) in componentList" :key="index" class="list" draggable :data-index="index"> <i :class="item.icon"></i> <span>{{ item.label }}</span> </div> </div>
handleDragStart(e) { e.dataTransfer.setData('index', e.target.dataset.index) }
能夠看到給列表中的每個組件都設置了 draggable
屬性。另外,在觸發 dragstart
事件時,使用 dataTransfer.setData()
傳輸數據。再來看一下接收數據的代碼:
<div class="content" @drop="handleDrop" @dragover="handleDragOver" @click="deselectCurComponent"> <Editor /> </div>
handleDrop(e) { e.preventDefault() e.stopPropagation() const component = deepCopy(componentList[e.dataTransfer.getData('index')]) this.$store.commit('addComponent', component) }
觸發 drop
事件時,使用 dataTransfer.getData()
接收傳輸過來的索引數據,而後根據索引找到對應的組件數據,再添加到畫布,從而渲染組件。
首先須要將畫布設爲相對定位 position: relative
,而後將每一個組件設爲絕對定位 position: absolute
。除了這一點外,還要經過監聽三個事件來進行移動:
mousedown
事件,在組件上按下鼠標時,記錄組件當前的位置,即 xy 座標(爲了方便講解,這裏使用的座標軸,實際上 xy 對應的是 css 中的 left
和 top
。mousemove
事件,每次鼠標移動時,都用當前最新的 xy 座標減去最開始的 xy 座標,從而計算出移動距離,再改變組件位置。mouseup
事件,鼠標擡起時結束移動。handleMouseDown(e) { e.stopPropagation() this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex }) const pos = { ...this.defaultStyle } const startY = e.clientY const startX = e.clientX // 若是直接修改屬性,值的類型會變爲字符串,因此要轉爲數值型 const startTop = Number(pos.top) const startLeft = Number(pos.left) const move = (moveEvent) => { const currX = moveEvent.clientX const currY = moveEvent.clientY pos.top = currY - startY + startTop pos.left = currX - startX + startLeft // 修改當前組件樣式 this.$store.commit('setShapeStyle', pos) } const up = () => { document.removeEventListener('mousemove', move) document.removeEventListener('mouseup', up) } document.addEventListener('mousemove', move) document.addEventListener('mouseup', up) }
因爲拖拽組件到畫布中是有前後順序的,因此能夠按照數據順序來分配圖層層級。
例如畫布新增了五個組件 abcde,那它們在畫布數據中的順序爲 [a, b, c, d, e]
,圖層層級和索引一一對應,即它們的 z-index
屬性值是 01234(後來居上)。用代碼表示以下:
<div v-for="(item, index) in componentData" :zIndex="index"></div>
若是不瞭解 z-index
屬性的,請看一下 MDN 文檔。
理解了這一點以後,改變圖層層級就很容易作到了。改變圖層層級,便是改變組件數據在 componentData
數組中的順序。例若有 [a, b, c]
三個組件,它們的圖層層級從低到高順序爲 abc(索引越大,層級越高)。
若是要將 b 組件上移,只需將它和 c 調換順序便可:
const temp = componentData[1] componentData[1] = componentData[2] componentData[2] = temp
同理,置頂置底也是同樣,例如我要將 a 組件置頂,只需將 a 和最後一個組件調換順序便可:
const temp = componentData[0] componentData[0] = componentData[componentData.lenght - 1] componentData[componentData.lenght - 1] = temp
刪除組件很是簡單,一行代碼搞定:componentData.splice(index, 1)
。
細心的網友可能會發現,點擊畫布上的組件時,組件上會出現 8 個小圓點。這 8 個小圓點就是用來放大縮小用的。實現原理以下:
Shape
組件,Shape
組件裏包含 8 個小圓點和一個 <slot>
插槽,用於放置組件。<!--頁面組件列表展現--> <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
組件內部結構:
<template> <div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown" @contextmenu="handleContextMenu"> <div class="shape-point" v-for="(item, index) in (active? pointList : [])" @mousedown="handleMouseDownOnPoint(item)" :key="index" :style="getPointStyle(item)"> </div> <slot></slot> </div> </template>
起做用的是這行代碼 :active="item === curComponent"
。
先來看一下計算小圓點位置的代碼:
const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'] getPointStyle(point) { const { width, height } = this.defaultStyle const hasT = /t/.test(point) const hasB = /b/.test(point) const hasL = /l/.test(point) const hasR = /r/.test(point) let newLeft = 0 let newTop = 0 // 四個角的點 if (point.length === 2) { newLeft = hasL? 0 : width newTop = hasT? 0 : height } else { // 上下兩點的點,寬度居中 if (hasT || hasB) { newLeft = width / 2 newTop = hasT? 0 : height } // 左右兩邊的點,高度居中 if (hasL || hasR) { newLeft = hasL? 0 : width newTop = Math.floor(height / 2) } } const style = { marginLeft: hasR? '-4px' : '-3px', marginTop: '-3px', left: `${newLeft}px`, top: `${newTop}px`, cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize', } return style }
計算小圓點的位置須要獲取一些信息:
height
、寬度 width
注意,小圓點也是絕對定位的,相對於 Shape
組件。因此有四個小圓點的位置很好肯定:
left: 0, top: 0
left: width, top: 0
left: 0, top: height
left: width, top: height
另外的四個小圓點須要經過計算間接算出來。例如左邊中間的小圓點,計算公式爲 left: 0, top: height / 2
,其餘小圓點同理。
handleMouseDownOnPoint(point) { const downEvent = window.event downEvent.stopPropagation() downEvent.preventDefault() const pos = { ...this.defaultStyle } const height = Number(pos.height) const width = Number(pos.width) const top = Number(pos.top) const left = Number(pos.left) const startX = downEvent.clientX const startY = downEvent.clientY // 是否須要保存快照 let needSave = false const move = (moveEvent) => { needSave = true const currX = moveEvent.clientX const currY = moveEvent.clientY const disY = currY - startY const disX = currX - startX const hasT = /t/.test(point) const hasB = /b/.test(point) const hasL = /l/.test(point) const hasR = /r/.test(point) const newHeight = height + (hasT? -disY : hasB? disY : 0) const newWidth = width + (hasL? -disX : hasR? disX : 0) pos.height = newHeight > 0? newHeight : 0 pos.width = newWidth > 0? newWidth : 0 pos.left = left + (hasL? disX : 0) pos.top = top + (hasT? disY : 0) this.$store.commit('setShapeStyle', pos) } const up = () => { document.removeEventListener('mousemove', move) document.removeEventListener('mouseup', up) needSave && this.$store.commit('recordSnapshot') } document.addEventListener('mousemove', move) document.addEventListener('mouseup', up) }
它的原理是這樣的:
撤銷重作的實現原理其實挺簡單的,先看一下代碼:
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
保存了 4 個快照。即 [a, b, c, d]
,對應的快照索引爲 3。若是這時進行了撤銷操做,咱們須要將快照索引減 1,而後將對應的快照數據賦值給畫布。
例如當前畫布數據是 d,進行撤銷後,索引 -1,如今畫布的數據是 c。
明白了撤銷,那重作就很好理解了,就是將快照索引加 1,而後將對應的快照數據賦值給畫布。
不過還有一點要注意,就是在撤銷操做中進行了新的操做,要怎麼辦呢?有兩種解決方案:
[a, b, c, d]
舉例,假設如今進行了兩次撤銷操做,快照索引變爲 1,對應的快照數據爲 b,若是這時進行了新的操做,對應的快照數據爲 e。那 e 會把 cd 頂掉,如今的快照數據爲 [a, b, e]
。[a, b, e, c, d]
。我採用的是第一種方案。
什麼是吸附?就是在拖拽組件時,若是它和另外一個組件的距離比較接近,就會自動吸附在一塊兒。
吸附的代碼大概在 300 行左右,建議本身打開源碼文件看(文件路徑:src\\components\\Editor\\MarkLine.vue
)。這裏不貼代碼了,主要說說原理是怎麼實現的。
在頁面上建立 6 條線,分別是三橫三豎。這 6 條線的做用是對齊,它們何時會出現呢?
具體的計算公式主要是根據每一個組件的 xy 座標和寬度高度進行計算的。例如要判斷 ab 兩個組件的左邊是否對齊,則要知道它們每一個組件的 x 座標;若是要知道它們右邊是否對齊,除了要知道 x 座標,還要知道它們各自的寬度。
// 左對齊的條件 a.x == b.x // 右對齊的條件 a.x + a.width == b.x + b.width
在對齊的時候,顯示標線。
另外還要判斷 ab 兩個組件是否「足夠」近。若是足夠近,就吸附在一塊兒。是否足夠近要靠一個變量來判斷:
diff: 3, // 相距 dff 像素將自動吸附
小於等於 diff
像素則自動吸附。
吸附效果是怎麼實現的呢?
假設如今有 ab 組件,a 組件座標 xy 都是 0,寬高都是 100。如今假設 a 組件不動,咱們正在拖拽 b 組件。當把 b 組件拖到座標爲 x: 0, y: 103
時,因爲 103 - 100 <= 3(diff)
,因此能夠斷定它們已經接近得足夠近。這時須要手動將 b 組件的 y 座標值設爲 100,這樣就將 ab 組件吸附在一塊兒了。
在拖拽時若是 6 條標線都顯示出來會不太美觀。因此咱們能夠作一下優化,在縱橫方向上最多隻同時顯示一條線。實現原理以下:
能夠發現,關鍵的地方是咱們要知道兩個組件的方向。即 ab 兩個組件靠近,咱們要知道到底 b 是在 a 的左邊仍是右邊。
這一點能夠經過鼠標移動事件來判斷,以前在講解拖拽的時候說過,mousedown
事件觸發時會記錄起點座標。因此每次觸發 mousemove
事件時,用當前座標減去原來的座標,就能夠判斷組件方向。例如 x 方向上,若是 b.x - a.x
的差值爲正,說明是 b 在 a 右邊,不然爲左邊。
// 觸發元素移動事件,用於顯示標線、吸附功能 // 後面兩個參數表明鼠標移動方向 // currY - startY > 0 true 表示向下移動 false 表示向上移動 // currX - startX > 0 true 表示向右移動 false 表示向左移動 eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)
每一個組件都有一些通用屬性和獨有的屬性,咱們須要提供一個能顯示和修改屬性的地方。
// 每一個組件數據大概是這樣 { 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
將組件和值綁定在一塊兒。
預覽和編輯的渲染原理是同樣的,區別是不須要編輯功能。因此只須要將原先渲染組件的代碼稍微改一下就能夠了。
<!--頁面組件列表展現--> <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
保存在本地。
每一個組件有一個 events
對象,用於存儲綁定的事件。目前我只定義了兩個事件:
// 編輯器自定義事件 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]) }) }
動畫和事件的原理是同樣的,先將全部的動畫經過 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
一塊兒使用,就能夠逐個運行組件的每一個動畫了。
因爲時間關係,這個功能我還沒作。如今簡單的描述一下怎麼作這個功能。那就是使用 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 轉換功能。
因爲畫布是能夠調整大小的,咱們可使用 iphone6 的分辨率來開發手機頁面。
這樣開發出來的頁面也能夠在手機下正常瀏覽,但可能會有樣式誤差。由於我自定義的三個組件是沒有作適配的,若是你須要開發手機頁面,那自定義組件必須使用移動端的 UI 組件庫。或者本身開發移動端專用的自定義組件。
因爲 DEMO 的代碼比較多,因此在講解每個功能點時,我只把關鍵代碼貼上來。因此你們會發現 DEMO 的源碼和我貼上來的代碼會有些區別,請沒必要在乎。
另外,DEMO 的樣式也比較簡陋,主要是最近事情比較多,沒太多時間寫好看點,請見諒。