本文是對《視覺化拖拽元件庫一些技術要點原理分析》的補充。上一篇文章主要講解了以下幾個功能點:
- 編輯器
- 自定義元件
- 拖拽
- 刪除元件、調整圖層層級
- 放大縮小
- 撤消、重做
- 元件屬性設定
- 吸附
- 預覽、儲存程式碼
- 繫結事件
- 繫結動畫
- 匯入 PSD
- 手機模式
現在這篇文章會在此基礎上再補充 4 個功能點,分別是:
- 拖拽旋轉
- 複製貼上剪下
- 資料互動
- 釋出
和上篇文章一樣,我已經將新功能的程式碼更新到了 github:
友善提醒:建議結合原始碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。
14. 拖拽旋轉
在寫上一篇文章時,原來的 DEMO 已經可以支援旋轉功能了。但是這個旋轉功能還有很多不完善的地方:
- 不支援拖拽旋轉。
- 旋轉後的放大縮小不正確。
- 旋轉後的自動吸附不正確。
- 旋轉後八個可伸縮點的游標不正確。
這一小節,我們將逐一解決這四個問題。
拖拽旋轉
拖拽旋轉需要使用 Math.atan2() 函式。
Math.atan2() 返回從原點(0,0)到(x,y)點的線段與x軸正方向之間的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相對於圓點(0,0)的距離。
簡單的說就是以元件中心點為原點 (centerX,centerY)
,使用者按下滑鼠時的座標設為 (startX,startY)
,滑鼠移動時的座標設為 (curX,curY)
。旋轉角度可以通過 (startX,startY)
和 (curX,curY)
計算得出。
那我們如何得到從點 (startX,startY)
到點 (curX,curY)
之間的旋轉角度呢?
第一步,滑鼠點選時的座標設為 (startX,startY)
:
const startY = e.clientY
const startX = e.clientX
第二步,算出元件中心點:
// 獲取元件中心點位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
第三步,按住滑鼠移動時的座標設為 (curX,curY)
:
const curX = moveEvent.clientX
const curY = moveEvent.clientY
第四步,分別算出 (startX,startY)
和 (curX,curY)
對應的角度,再將它們相減得出旋轉的角度。另外,還需要注意的就是 Math.atan2()
方法的返回值是一個弧度,因此還需要將弧度轉化為角度。所以完整的程式碼為:
// 旋轉前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋轉後的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 獲取旋轉的角度值, startRotate 為初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore
放大縮小
元件旋轉後的放大縮小會有 BUG。
從上圖可以看到,放大縮小時會發生移位。另外伸縮的方向和我們拖動的方向也不對。造成這一 BUG 的原因是:當初設計放大縮小功能沒有考慮到旋轉的場景。所以無論旋轉多少角度,放大縮小仍然是按沒旋轉時計算的。
下面再看一個具體的示例:
從上圖可以看出,在沒有旋轉時,按住頂點往上拖動,只需用 y2 - y1
就可以得出拖動距離 s
。這時將元件原來的高度加上 s
就能得出新的高度,同時將元件的 top
、left
屬性更新。
現在旋轉 180 度,如果這時拖住頂點往下拖動,我們期待的結果是元件高度增加。但這時計算的方式和原來沒旋轉時是一樣的,所以結果和我們期待的相反,元件的高度將會變小(如果不理解這個現象,可以想像一下沒有旋轉的那張圖,按住頂點往下拖動)。
如何解決這個問題呢?我從 github 上的一個專案 snapping-demo 找到了解決方案:將放大縮小和旋轉角度關聯起來。
解決方案
下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。
現在我們將一步步分析如何得出拉伸後的元件的正確大小和位移。
第一步,按下滑鼠時通過元件的座標(無論旋轉多少度,元件的 top
left
屬性不變)和大小算出元件中心點:
const center = {
x: style.left + style.width / 2,
y: style.top + style.height / 2,
}
第二步,用當前點選座標和元件中心點算出當前點選座標的對稱點座標:
// 獲取畫布位移資訊
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()
// 當前點選座標
const curPoint = {
x: e.clientX - editorRectInfo.left,
y: e.clientY - editorRectInfo.top,
}
// 獲取對稱點的座標
const symmetricPoint = {
x: center.x - (curPoint.x - center.x),
y: center.y - (curPoint.y - center.y),
}
第三步,摁住元件左上角進行拉伸時,通過當前滑鼠實時座標和對稱點計算出新的元件中心點:
const curPositon = {
x: moveEvent.clientX - editorRectInfo.left,
y: moveEvent.clientY - editorRectInfo.top,
}
const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
// 求兩點之間的中點座標
function getCenterPoint(p1, p2) {
return {
x: p1.x + ((p2.x - p1.x) / 2),
y: p1.y + ((p2.y - p1.y) / 2),
}
}
由於元件處於旋轉狀態,即使你知道了拉伸時移動的 xy
距離,也不能直接對元件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在元件未旋轉的情況下對其進行計算。
第四步,根據已知的旋轉角度、新的元件中心點、當前滑鼠實時座標可以算出當前滑鼠實時座標 currentPosition
在未旋轉時的座標 newTopLeftPoint
。同時也能根據已知的旋轉角度、新的元件中心點、對稱點算出元件對稱點 sPoint
在未旋轉時的座標 newBottomRightPoint
。
對應的計算公式如下:
/**
* 計算根據圓心旋轉後的點的座標
* @param {Object} point 旋轉前的點座標
* @param {Object} center 旋轉中心
* @param {Number} rotate 旋轉的角度
* @return {Object} 旋轉後的座標
* https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
*/
export function calculateRotatedPointCoordinate(point, center, rotate) {
/**
* 旋轉公式:
* 點a(x, y)
* 旋轉中心c(x, y)
* 旋轉後點n(x, y)
* 旋轉角度θ tan ??
* nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
* ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
*/
return {
x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
}
}
上面的公式涉及到線性代數中旋轉矩陣的知識,對於一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:
通過以上幾個計算值,就可以得到元件新的位移值 top
left
以及新的元件大小。對應的完整程式碼如下:
function calculateLeftTop(style, curPositon, pointInfo) {
const { symmetricPoint } = pointInfo
const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth)
style.height = Math.round(newHeight)
style.left = Math.round(newTopLeftPoint.x)
style.top = Math.round(newTopLeftPoint.y)
}
}
現在再來看一下旋轉後的放大縮小:
自動吸附
自動吸附是根據元件的四個屬性 top
left
width
height
計算的,在將元件進行旋轉後,這些屬性的值是不會變的。所以無論元件旋轉多少度,吸附時仍然按未旋轉時計算。這樣就會有一個問題,雖然實際上元件的 top
left
width
height
屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的元件:一個沒旋轉,一個旋轉了 45 度。
可以看出來旋轉後按鈕的 height
屬性和我們從外觀上看到的高度是不一樣的,所以在這種情況下就出現了吸附不正確的 BUG。
解決方案
如何解決這個問題?我們需要拿元件旋轉後的大小及位移來做吸附對比。也就是說不要拿元件實際的屬性來對比,而是拿我們看到的大小和位移做對比。
從上圖可以看出,旋轉後的元件在 x 軸上的投射長度為兩條紅線長度之和。這兩條紅線的長度可以通過正弦和餘弦算出,左邊的紅線用正弦計算,右邊的紅線用餘弦計算:
const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
同理,高度也是一樣:
const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
新的寬度和高度有了,再根據元件原有的 top
left
屬性,可以得出元件旋轉後新的 top
left
屬性。下面附上完整程式碼:
translateComponentStyle(style) {
style = { ...style }
if (style.rotate != 0) {
const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
const diffX = (style.width - newWidth) / 2
style.left += diffX
style.right = style.left + newWidth
const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
const diffY = (newHeight - style.height) / 2
style.top -= diffY
style.bottom = style.top + newHeight
style.width = newWidth
style.height = newHeight
} else {
style.bottom = style.top + style.height
style.right = style.left + style.width
}
return style
}
經過修復後,吸附也可以正常顯示了。
游標
游標和可拖動的方向不對,是因為八個點的游標是固定設定的,沒有隨著角度變化而變化。
解決方案
由於 360 / 8 = 45
,所以可以為每一個方向分配 45 度的範圍,每個範圍對應一個游標。同時為每個方向設定一個初始角度,也就是未旋轉時元件每個方向對應的角度。
pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八個方向
initialAngle: { // 每個點對應的初始角度
lt: 0,
t: 45,
rt: 90,
r: 135,
rb: 180,
b: 225,
lb: 270,
l: 315,
},
angleToCursor: [ // 每個範圍的角度對應的游標
{ start: 338, end: 23, cursor: 'nw' },
{ start: 23, end: 68, cursor: 'n' },
{ start: 68, end: 113, cursor: 'ne' },
{ start: 113, end: 158, cursor: 'e' },
{ start: 158, end: 203, cursor: 'se' },
{ start: 203, end: 248, cursor: 's' },
{ start: 248, end: 293, cursor: 'sw' },
{ start: 293, end: 338, cursor: 'w' },
],
cursors: {},
計算方式也很簡單:
- 假設現在元件已旋轉了一定的角度 a。
- 遍歷八個方向,用每個方向的初始角度 + a 得出現在的角度 b。
- 遍歷
angleToCursor
陣列,看看 b 在哪一個範圍中,然後將對應的游標返回。
經常上面三個步驟就可以計算出元件旋轉後正確的游標方向。具體的程式碼如下:
getCursor() {
const { angleToCursor, initialAngle, pointList, curComponent } = this
const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有負數,所以 + 360
const result = {}
let lastMatchIndex = -1 // 從上一個命中的角度的索引開始匹配下一個,降低時間複雜度
pointList.forEach(point => {
const angle = (initialAngle[point] + rotate) % 360
const len = angleToCursor.length
while (true) {
lastMatchIndex = (lastMatchIndex + 1) % len
const angleLimit = angleToCursor[lastMatchIndex]
if (angle < 23 || angle >= 338) {
result[point] = 'nw-resize'
return
}
if (angleLimit.start <= angle && angle < angleLimit.end) {
result[point] = angleLimit.cursor + '-resize'
return
}
}
})
return result
},
從上面的動圖可以看出來,現在八個方向上的游標是可以正確顯示的。
15. 複製貼上剪下
相對於拖拽旋轉功能,複製貼上就比較簡單了。
const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false
window.onkeydown = (e) => {
if (e.keyCode == ctrlKey) {
isCtrlDown = true
} else if (isCtrlDown && e.keyCode == cKey) {
this.$store.commit('copy')
} else if (isCtrlDown && e.keyCode == vKey) {
this.$store.commit('paste')
} else if (isCtrlDown && e.keyCode == xKey) {
this.$store.commit('cut')
}
}
window.onkeyup = (e) => {
if (e.keyCode == ctrlKey) {
isCtrlDown = false
}
}
監聽使用者的按鍵操作,在按下特定按鍵時觸發對應的操作。
複製操作
在 vuex 中使用 copyData
來表示複製的資料。當使用者按下 ctrl + c
時,將當前元件資料深拷貝到 copyData
。
copy(state) {
state.copyData = {
data: deepCopy(state.curComponent),
index: state.curComponentIndex,
}
},
同時需要將當前元件在元件資料中的索引記錄起來,在剪下中要用到。
貼上操作
paste(state, isMouse) {
if (!state.copyData) {
toast('請選擇元件')
return
}
const data = state.copyData.data
if (isMouse) {
data.style.top = state.menuTop
data.style.left = state.menuLeft
} else {
data.style.top += 10
data.style.left += 10
}
data.id = generateID()
store.commit('addComponent', { component: data })
store.commit('recordSnapshot')
state.copyData = null
},
貼上時,如果是按鍵操作 ctrl+v
。則將元件的 top
left
屬性加 10,以免和原來的元件重疊在一起。如果是使用滑鼠右鍵執行貼上操作,則將複製的元件放到滑鼠點選處。
剪下操作
cut(state) {
if (!state.curComponent) {
toast('請選擇元件')
return
}
if (state.copyData) {
store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
if (state.curComponentIndex >= state.copyData.index) {
// 如果當前元件索引大於等於插入索引,需要加一,因為當前元件往後移了一位
state.curComponentIndex++
}
}
store.commit('copy')
store.commit('deleteComponent')
},
剪下操作本質上還是複製,只不過在執行復制後,需要將當前元件刪除。為了避免使用者執行剪下操作後,不執行貼上操作,而是繼續執行剪下。這時就需要將原先剪下的資料進行恢復。所以複製資料中記錄的索引就起作用了,可以通過索引將原來的資料恢復到原來的位置中。
右鍵操作
右鍵操作和按鍵操作是一樣的,一個功能兩種觸發途徑。
<li @click="copy" v-show="curComponent">複製</li>
<li @click="paste">貼上</li>
<li @click="cut" v-show="curComponent">剪下</li>
cut() {
this.$store.commit('cut')
},
copy() {
this.$store.commit('copy')
},
paste() {
this.$store.commit('paste', true)
},
16. 資料互動
方式一
提前寫好一系列 ajax 請求API,點選元件時按需選擇 API,選好 API 再填引數。例如下面這個元件,就展示瞭如何使用 ajax 請求向後臺互動:
<template>
<div>{{ propValue.data }}</div>
</template>
<script>
export default {
// propValue: {
// api: {
// request: a,
// params,
// },
// data: null
// }
props: {
propValue: {
type: Object,
default: () => {},
},
},
created() {
this.propValue.api.request(this.propValue.api.params).then(res => {
this.propValue.data = res.data
})
},
}
</script>
方式二
方式二適合純展示的元件,例如有一個報警元件,可以根據後臺傳來的資料顯示對應的顏色。在編輯頁面的時候,可以通過 ajax 向後臺請求頁面能夠使用的 websocket 資料:
const data = ['status', 'text'...]
然後再為不同的元件新增上不同的屬性。例如有 a 元件,它繫結的屬性為 status
。
// 元件能接收的資料
props: {
propValue: {
type: String,
},
element: {
type: Object,
},
wsKey: {
type: String,
default: '',
},
},
在元件中通過 wsKey
獲取這個繫結的屬性。等頁面釋出後或者預覽時,通過 weboscket 向後臺請求全域性資料放在 vuex 上。元件就可以通過 wsKey
訪問資料了。
<template>
<div>{{ wsData[wsKey] }}</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
props: {
propValue: {
type: String,
},
element: {
type: Object,
},
wsKey: {
type: String,
default: '',
},
},
computed: mapState([
'wsData',
]),
</script>
和後臺互動的方式有很多種,不僅僅包括上面兩種,我在這裡僅提供一些思路,以供參考。
17. 釋出
頁面釋出有兩種方式:一是將元件資料渲染為一個單獨的 HTML 頁面;二是從本專案中抽取出一個最小執行時 runtime 作為一個單獨的專案。
這裡說一下第二種方式,本專案中的最小執行時其實就是預覽頁面加上自定義元件。將這些程式碼提取出來作為一個專案單獨打包。釋出頁面時將元件資料以 JSON 的格式傳給服務端,同時為每個頁面生成一個唯一 ID。
假設現在有三個頁面,釋出頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據 ID 獲取每個頁面對應的元件資料。
www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b
按需載入
如果自定義元件過大,例如有數十個甚至上百個。這時可以將自定義元件用 import
的方式匯入,做到按需載入,減少首屏渲染時間:
import Vue from 'vue'
const components = [
'Picture',
'VText',
'VButton',
]
components.forEach(key => {
Vue.component(key, () => import(`@/custom-component/${key}`))
})
按版本釋出
自定義元件有可能會有更新的情況。例如原來的元件使用了大半年,現在有功能變更,為了不影響原來的頁面。建議在釋出時帶上元件的版本號:
- v-text
- v1.vue
- v2.vue
例如 v-text
元件有兩個版本,在左側元件列表區使用時就可以帶上版本號:
{
component: 'v-text',
version: 'v1'
...
}
這樣匯入元件時就可以根據元件版本號進行匯入:
import Vue from 'vue'
import componentList from '@/custom-component/component-list`
componentList.forEach(component => {
Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})