<kbd id="5sdj3"></kbd>
<th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>

    可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析

    共 12517字,需瀏覽 26分鐘

     ·

    2021-01-19 08:15

    本文是對《可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析》[1]的補(bǔ)充。上一篇文章主要講解了以下幾個功能點(diǎn):

    1.編輯器2.自定義組件3.拖拽4.刪除組件、調(diào)整圖層層級5.放大縮小6.撤消、重做7.組件屬性設(shè)置8.吸附9.預(yù)覽、保存代碼10.綁定事件11.綁定動畫12.導(dǎo)入 PSD13.手機(jī)模式

    現(xiàn)在這篇文章會在此基礎(chǔ)上再補(bǔ)充 4 個功能點(diǎn),分別是:

    ?拖拽旋轉(zhuǎn)?復(fù)制粘貼剪切?數(shù)據(jù)交互?發(fā)布

    和上篇文章一樣,我已經(jīng)將新功能的代碼更新到了 github:

    ?github 項(xiàng)目地址[2]?在線預(yù)覽[3]

    友善提醒:建議結(jié)合源碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術(shù)棧)。

    14. 拖拽旋轉(zhuǎn)

    在寫上一篇文章時,原來的 DEMO 已經(jīng)可以支持旋轉(zhuǎn)功能了。但是這個旋轉(zhuǎn)功能還有很多不完善的地方:

    1.不支持拖拽旋轉(zhuǎn)。2.旋轉(zhuǎn)后的放大縮小不正確。3.旋轉(zhuǎn)后的自動吸附不正確。4.旋轉(zhuǎn)后八個可伸縮點(diǎn)的光標(biāo)不正確。

    這一小節(jié),我們將逐一解決這四個問題。

    拖拽旋轉(zhuǎn)

    拖拽旋轉(zhuǎn)需要使用?Math.atan2()[4]?函數(shù)。

    Math.atan2() 返回從原點(diǎn)(0,0)到(x,y)點(diǎn)的線段與x軸正方向之間的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相對于圓點(diǎn)(0,0)的距離。

    簡單的說就是以組件中心點(diǎn)為原點(diǎn)?(centerX,centerY),用戶按下鼠標(biāo)時的坐標(biāo)設(shè)為?(startX,startY),鼠標(biāo)移動時的坐標(biāo)設(shè)為?(curX,curY)。旋轉(zhuǎn)角度可以通過?(startX,startY)?和?(curX,curY)?計(jì)算得出。

    那我們?nèi)绾蔚玫綇狞c(diǎn)?(startX,startY)?到點(diǎn)?(curX,curY)?之間的旋轉(zhuǎn)角度呢?

    第一步,鼠標(biāo)點(diǎn)擊時的坐標(biāo)設(shè)為?(startX,startY)

    const startY = e.clientYconst startX = e.clientX

    第二步,算出組件中心點(diǎn):

    // 獲取組件中心點(diǎn)位置const rect = this.$el.getBoundingClientRect()const centerX = rect.left + rect.width / 2const centerY = rect.top + rect.height / 2

    第三步,按住鼠標(biāo)移動時的坐標(biāo)設(shè)為?(curX,curY)

    const curX = moveEvent.clientXconst curY = moveEvent.clientY

    第四步,分別算出?(startX,startY)?和?(curX,curY)?對應(yīng)的角度,再將它們相減得出旋轉(zhuǎn)的角度。另外,還需要注意的就是?Math.atan2()?方法的返回值是一個弧度,因此還需要將弧度轉(zhuǎn)化為角度。所以完整的代碼為:

    // 旋轉(zhuǎn)前的角度const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)// 旋轉(zhuǎn)后的角度const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)// 獲取旋轉(zhuǎn)的角度值, startRotate 為初始角度值pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

    放大縮小

    組件旋轉(zhuǎn)后的放大縮小會有 BUG。

    從上圖可以看到,放大縮小時會發(fā)生移位。另外伸縮的方向和我們拖動的方向也不對。造成這一 BUG 的原因是:當(dāng)初設(shè)計(jì)放大縮小功能沒有考慮到旋轉(zhuǎn)的場景。所以無論旋轉(zhuǎn)多少角度,放大縮小仍然是按沒旋轉(zhuǎn)時計(jì)算的。

    下面再看一個具體的示例:

    從上圖可以看出,在沒有旋轉(zhuǎn)時,按住頂點(diǎn)往上拖動,只需用?y2 - y1?就可以得出拖動距離?s。這時將組件原來的高度加上?s?就能得出新的高度,同時將組件的?top、left?屬性更新。

    現(xiàn)在旋轉(zhuǎn) 180 度,如果這時拖住頂點(diǎn)往下拖動,我們期待的結(jié)果是組件高度增加。但這時計(jì)算的方式和原來沒旋轉(zhuǎn)時是一樣的,所以結(jié)果和我們期待的相反,組件的高度將會變?。ㄈ绻焕斫膺@個現(xiàn)象,可以想像一下沒有旋轉(zhuǎn)的那張圖,按住頂點(diǎn)往下拖動)。

    如何解決這個問題呢?我從 github 上的一個項(xiàng)目?snapping-demo[5]?找到了解決方案:將放大縮小和旋轉(zhuǎn)角度關(guān)聯(lián)起來。

    解決方案

    下面是一個已旋轉(zhuǎn)一定角度的矩形,假設(shè)現(xiàn)在拖動它左上方的點(diǎn)進(jìn)行拉伸。

    現(xiàn)在我們將一步步分析如何得出拉伸后的組件的正確大小和位移。

    第一步,按下鼠標(biāo)時通過組件的坐標(biāo)(無論旋轉(zhuǎn)多少度,組件的?top?left?屬性不變)和大小算出組件中心點(diǎn):

    const center = {    x: style.left + style.width / 2,    y: style.top + style.height / 2,}

    第二步,用當(dāng)前點(diǎn)擊坐標(biāo)和組件中心點(diǎn)算出當(dāng)前點(diǎn)擊坐標(biāo)的對稱點(diǎn)坐標(biāo):

    // 獲取畫布位移信息const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()
    // 當(dāng)前點(diǎn)擊坐標(biāo)const curPoint = { x: e.clientX - editorRectInfo.left, y: e.clientY - editorRectInfo.top,}
    // 獲取對稱點(diǎn)的坐標(biāo)const symmetricPoint = { x: center.x - (curPoint.x - center.x), y: center.y - (curPoint.y - center.y),}

    第三步,摁住組件左上角進(jìn)行拉伸時,通過當(dāng)前鼠標(biāo)實(shí)時坐標(biāo)和對稱點(diǎn)計(jì)算出新的組件中心點(diǎn):

    const curPositon = {    x: moveEvent.clientX - editorRectInfo.left,    y: moveEvent.clientY - editorRectInfo.top,}
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    // 求兩點(diǎn)之間的中點(diǎn)坐標(biāo)function getCenterPoint(p1, p2) { return { x: p1.x + ((p2.x - p1.x) / 2), y: p1.y + ((p2.y - p1.y) / 2), }}

    由于組件處于旋轉(zhuǎn)狀態(tài),即使你知道了拉伸時移動的?xy?距離,也不能直接對組件進(jìn)行計(jì)算。否則就會出現(xiàn) BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉(zhuǎn)的情況下對其進(jìn)行計(jì)算。

    第四步,根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點(diǎn)、當(dāng)前鼠標(biāo)實(shí)時坐標(biāo)可以算出當(dāng)前鼠標(biāo)實(shí)時坐標(biāo)?currentPosition?在未旋轉(zhuǎn)時的坐標(biāo)?newTopLeftPoint。同時也能根據(jù)已知的旋轉(zhuǎn)角度、新的組件中心點(diǎn)、對稱點(diǎn)算出組件對稱點(diǎn)?sPoint?在未旋轉(zhuǎn)時的坐標(biāo)?newBottomRightPoint。

    對應(yīng)的計(jì)算公式如下:

    /** * 計(jì)算根據(jù)圓心旋轉(zhuǎn)后的點(diǎn)的坐標(biāo) * @param   {Object}  point  旋轉(zhuǎn)前的點(diǎn)坐標(biāo) * @param   {Object}  center 旋轉(zhuǎn)中心 * @param   {Number}  rotate 旋轉(zhuǎn)的角度 * @return  {Object}         旋轉(zhuǎn)后的坐標(biāo) * https://www.zhihu.com/question/67425734/answer/252724399 旋轉(zhuǎn)矩陣公式 */export function calculateRotatedPointCoordinate(point, center, rotate) {    /**     * 旋轉(zhuǎn)公式:     *  點(diǎn)a(x, y)     *  旋轉(zhuǎn)中心c(x, y)     *  旋轉(zhuǎn)后點(diǎn)n(x, y)     *  旋轉(zhuǎn)角度θ                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, }}

    上面的公式涉及到線性代數(shù)中旋轉(zhuǎn)矩陣的知識,對于一個沒上過大學(xué)的人來說,實(shí)在太難了。還好我從知乎上的一個回答[6]中找到了這一公式的推理過程,下面是回答的原文:

    通過以上幾個計(jì)算值,就可以得到組件新的位移值?top?left?以及新的組件大小。對應(yīng)的完整代碼如下:

    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) }}

    現(xiàn)在再來看一下旋轉(zhuǎn)后的放大縮小:

    自動吸附

    自動吸附是根據(jù)組件的四個屬性?top?left?width?height?計(jì)算的,在將組件進(jìn)行旋轉(zhuǎn)后,這些屬性的值是不會變的。所以無論組件旋轉(zhuǎn)多少度,吸附時仍然按未旋轉(zhuǎn)時計(jì)算。這樣就會有一個問題,雖然實(shí)際上組件的?top?left?width?height?屬性沒有變化。但在外觀上卻發(fā)生了變化。下面是兩個同樣的組件:一個沒旋轉(zhuǎn),一個旋轉(zhuǎn)了 45 度。

    可以看出來旋轉(zhuǎn)后按鈕的?height?屬性和我們從外觀上看到的高度是不一樣的,所以在這種情況下就出現(xiàn)了吸附不正確的 BUG。

    解決方案

    如何解決這個問題?我們需要拿組件旋轉(zhuǎn)后的大小及位移來做吸附對比。也就是說不要拿組件實(shí)際的屬性來對比,而是拿我們看到的大小和位移做對比。

    從上圖可以看出,旋轉(zhuǎn)后的組件在 x 軸上的投射長度為兩條紅線長度之和。這兩條紅線的長度可以通過正弦和余弦算出,左邊的紅線用正弦計(jì)算,右邊的紅線用余弦計(jì)算:

    const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

    同理,高度也是一樣:

    const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

    新的寬度和高度有了,再根據(jù)組件原有的?top?left?屬性,可以得出組件旋轉(zhuǎn)后新的?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}

    經(jīng)過修復(fù)后,吸附也可以正常顯示了。

    光標(biāo)

    光標(biāo)和可拖動的方向不對,是因?yàn)榘藗€點(diǎn)的光標(biāo)是固定設(shè)置的,沒有隨著角度變化而變化。

    解決方案

    由于?360 / 8 = 45,所以可以為每一個方向分配 45 度的范圍,每個范圍對應(yīng)一個光標(biāo)。同時為每個方向設(shè)置一個初始角度,也就是未旋轉(zhuǎn)時組件每個方向?qū)?yīng)的角度。

    pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八個方向initialAngle: { // 每個點(diǎn)對應(yīng)的初始角度    lt: 0,    t: 45,    rt: 90,    r: 135,    rb: 180,    b: 225,    lb: 270,    l: 315,},angleToCursor: [ // 每個范圍的角度對應(yīng)的光標(biāo)    { 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: {},

    計(jì)算方式也很簡單:

    1.假設(shè)現(xiàn)在組件已旋轉(zhuǎn)了一定的角度 a。2.遍歷八個方向,用每個方向的初始角度 + a 得出現(xiàn)在的角度 b。3.遍歷?angleToCursor?數(shù)組,看看 b 在哪一個范圍中,然后將對應(yīng)的光標(biāo)返回。

    經(jīng)常上面三個步驟就可以計(jì)算出組件旋轉(zhuǎn)后正確的光標(biāo)方向。具體的代碼如下:

    getCursor() {  const { angleToCursor, initialAngle, pointList, curComponent } = this  const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有負(fù)數(shù),所以 + 360  const result = {}  let lastMatchIndex = -1 // 從上一個命中的角度的索引開始匹配下一個,降低時間復(fù)雜度  pointList.forEach(point => {      const angle = (initialAngle[point] + rotate) % 360      const len = angleToCursor.length      let i = 0      while (i < len) {          lastMatchIndex = (lastMatchIndex + 1) % len          const angleLimit = angleToCursor[lastMatchIndex]          if (angle < 23 || angle >= 338) {              result[point] = 'nw-resize'              break          }
    if (angleLimit.start <= angle && angle < angleLimit.end) { result[point] = angleLimit.cursor + '-resize' break } } })
    return result},

    從上面的動圖可以看出來,現(xiàn)在八個方向上的光標(biāo)是可以正確顯示的。

    15. 復(fù)制粘貼剪切

    相對于拖拽旋轉(zhuǎn)功能,復(fù)制粘貼就比較簡單了。

    const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88let 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 }}

    監(jiān)聽用戶的按鍵操作,在按下特定按鍵時觸發(fā)對應(yīng)的操作。

    復(fù)制操作

    在 vuex 中使用?copyData?來表示復(fù)制的數(shù)據(jù)。當(dāng)用戶按下?ctrl + c?時,將當(dāng)前組件數(shù)據(jù)深拷貝到?copyData

    copy(state) {    state.copyData = {        data: deepCopy(state.curComponent),        index: state.curComponentIndex,    }},

    同時需要將當(dāng)前組件在組件數(shù)據(jù)中的索引記錄起來,在剪切中要用到。

    粘貼操作

    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,以免和原來的組件重疊在一起。如果是使用鼠標(biāo)右鍵執(zhí)行粘貼操作,則將復(fù)制的組件放到鼠標(biāo)點(diǎn)擊處。

    剪切操作

    cut({ copyData }) {    if (copyData) {        store.commit('addComponent', { component: copyData.data, index: copyData.index })    }
    store.commit('copy') store.commit('deleteComponent')},

    剪切操作本質(zhì)上還是復(fù)制,只不過在執(zhí)行復(fù)制后,需要將當(dāng)前組件刪除。為了避免用戶執(zhí)行剪切操作后,不執(zhí)行粘貼操作,而是繼續(xù)執(zhí)行剪切。這時就需要將原先剪切的數(shù)據(jù)進(jìn)行恢復(fù)。所以復(fù)制數(shù)據(jù)中記錄的索引就起作用了,可以通過索引將原來的數(shù)據(jù)恢復(fù)到原來的位置中。

    右鍵操作

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

    <li @click="copy" v-show="curComponent">復(fù)制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. 數(shù)據(jù)交互

    方式一

    提前寫好一系列 ajax 請求API,點(diǎn)擊組件時按需選擇 API,選好 API 再填參數(shù)。例如下面這個組件,就展示了如何使用 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>

    方式二

    方式二適合純展示的組件,例如有一個報警組件,可以根據(jù)后臺傳來的數(shù)據(jù)顯示對應(yīng)的顏色。在編輯頁面的時候,可以通過 ajax 向后臺請求頁面能夠使用的 websocket 數(shù)據(jù):

    const data = ['status', 'text'...]

    然后再為不同的組件添加上不同的屬性。例如有 a 組件,它綁定的屬性為?status

    // 組件能接收的數(shù)據(jù)props: {    propValue: {        type: String,    },    element: {        type: Object,    },    wsKey: {        type: String,        default: '',    },},

    在組件中通過?wsKey?獲取這個綁定的屬性。等頁面發(fā)布后或者預(yù)覽時,通過 weboscket 向后臺請求全局?jǐn)?shù)據(jù)放在 vuex 上。組件就可以通過?wsKey?訪問數(shù)據(jù)了。

    <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. 發(fā)布

    頁面發(fā)布有兩種方式:一是將組件數(shù)據(jù)渲染為一個單獨(dú)的 HTML 頁面;二是從本項(xiàng)目中抽取出一個最小運(yùn)行時 runtime 作為一個單獨(dú)的項(xiàng)目。

    這里說一下第二種方式,本項(xiàng)目中的最小運(yùn)行時其實(shí)就是預(yù)覽頁面加上自定義組件。將這些代碼提取出來作為一個項(xiàng)目單獨(dú)打包。發(fā)布頁面時將組件數(shù)據(jù)以 JSON 的格式傳給服務(wù)端,同時為每個頁面生成一個唯一 ID。

    假設(shè)現(xiàn)在有三個頁面,發(fā)布頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據(jù) ID 獲取每個頁面對應(yīng)的組件數(shù)據(jù)。

    www.test.com/?id=awww.test.com/?id=cwww.test.com/?id=b

    按需加載

    如果自定義組件過大,例如有數(shù)十個甚至上百個。這時可以將自定義組件用?import?的方式導(dǎo)入,做到按需加載,減少首屏渲染時間:

    import Vue from 'vue'
    const components = [ 'Picture', 'VText', 'VButton',]
    components.forEach(key => { Vue.component(key, () => import(`@/custom-component/${key}`))})

    按版本發(fā)布

    自定義組件有可能會有更新的情況。例如原來的組件使用了大半年,現(xiàn)在有功能變更,為了不影響原來的頁面。建議在發(fā)布時帶上組件的版本號:

    - v-text  - v1.vue  - v2.vue

    例如?v-text?組件有兩個版本,在左側(cè)組件列表區(qū)使用時就可以帶上版本號:

    {  component: 'v-text',  version: 'v1'  ...}

    這樣導(dǎo)入組件時就可以根據(jù)組件版本號進(jìn)行導(dǎo)入:

    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[7]?通過Math.atan2 計(jì)算角度[8]?為什么矩陣能用來表示角的旋轉(zhuǎn)?[9]?snapping-demo[10]?vue-next-drag[11]

    References

    [1]?《可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析》:?https://juejin.cn/post/6908502083075325959
    [2]?github 項(xiàng)目地址:?https://github.com/woai3c/visual-drag-demo
    [3]?在線預(yù)覽:?https://woai3c.github.io/visual-drag-demo
    [4]?Math.atan2():?https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2
    [5]?snapping-demo:?https://github.com/shenhudong/snapping-demo/wiki/corner-handle
    [6]?回答:?https://www.zhihu.com/question/67425734/answer/252724399
    [7]?Math:?https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math
    [8]?通過Math.atan2 計(jì)算角度:?https://www.jianshu.com/p/9817e267925a
    [9]?為什么矩陣能用來表示角的旋轉(zhuǎn)?:?https://www.zhihu.com/question/67425734/answer/252724399
    [10]?snapping-demo:?https://github.com/shenhudong/snapping-demo/wiki/corner-handle
    [11]?vue-next-drag:?https://github.com/lycHub/vue-next-drag


    瀏覽 75
    點(diǎn)贊
    評論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報
    評論
    圖片
    表情
    推薦
    點(diǎn)贊
    評論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報

    <kbd id="5sdj3"></kbd>
    <th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>
    亚洲精品一级二级三级 | 青青草黄色免费视频 | 免费看h网站 | 豆花视频精品一二三区 | 欧美日韩网 |