視覺化拖拽元件庫一些技術要點原理分析(二)

本文是對《

視覺化拖拽元件庫一些技術要點原理分析

》的補充。上一篇文章主要講解了以下幾個功能點:

編輯器

自定義元件

拖拽

刪除元件、調整圖層層級

放大縮小

撤消、重做

元件屬性設定

吸附

預覽、儲存程式碼

繫結事件

繫結動畫

匯入 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。clientYconst startX = e。clientX

第二步

,算出元件中心點:

// 獲取元件中心點位置const rect = this。$el。getBoundingClientRect()const centerX = rect。left + rect。width / 2const centerY = rect。top + rect。height / 2

第三步

,按住滑鼠移動時的座標設為

(curX,curY)

const curX = moveEvent。clientXconst 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 = 88let isCtrlDown = falsewindow。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’)},

剪下操作本質上還是複製,只不過在執行復制後,需要將當前元件刪除。為了避免使用者執行剪下操作後,不執行貼上操作,而是繼續執行剪下。這時就需要將原先剪下的資料進行恢復。所以複製資料中記錄的索引就起作用了,可以透過索引將原來的資料恢復到原來的位置中。

右鍵操作

右鍵操作和按鍵操作是一樣的,一個功能兩種觸發途徑。

  • 複製
  • 貼上
  • 剪下
  • cut() { this。$store。commit(‘cut’)},copy() { this。$store。commit(‘copy’)},paste() { this。$store。commit(‘paste’, true)},

    16。 資料互動

    方式一

    提前寫好一系列 ajax 請求API,點選元件時按需選擇 API,選好 API 再填引數。例如下面這個元件,就展示瞭如何使用 ajax 請求向後臺互動:

    方式二

    方式二適合純展示的元件,例如有一個報警元件,可以根據後臺傳來的資料顯示對應的顏色。在編輯頁面的時候,可以透過 ajax 向後臺請求頁面能夠使用的 websocket 資料:

    const data = [‘status’, ‘text’。。。]

    然後再為不同的元件新增上不同的屬性。例如有 a 元件,它繫結的屬性為

    status

    // 元件能接收的資料props: { propValue: { type: String, }, element: { type: Object, }, wsKey: { type: String, default: ‘’, },},

    在元件中透過

    wsKey

    獲取這個繫結的屬性。等頁面釋出後或者預覽時,透過 weboscket 向後臺請求全域性資料放在 vuex 上。元件就可以透過

    wsKey

    訪問資料了。

    和後臺互動的方式有很多種,不僅僅包括上面兩種,我在這裡僅提供一些思路,以供參考。

    17。 釋出

    頁面釋出有兩種方式:一是將元件資料渲染為一個單獨的 HTML 頁面;二是從本專案中抽取出一個最小執行時 runtime 作為一個單獨的專案。

    這裡說一下第二種方式,本專案中的最小執行時其實就是預覽頁面加上自定義元件。將這些程式碼提取出來作為一個專案單獨打包。釋出頁面時將元件資料以 JSON 的格式傳給服務端,同時為每個頁面生成一個唯一 ID。

    假設現在有三個頁面,釋出頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據 ID 獲取每個頁面對應的元件資料。

    www。test。com/?id=awww。test。com/?id=cwww。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}`))})

    參考資料

    Math

    透過Math。atan2 計算角度

    為什麼矩陣能用來表示角的旋轉?

    snapping-demo

    vue-next-drag