<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)原理分析

    共 32461字,需瀏覽 65分鐘

     ·

    2024-05-27 09:21

    本文是可視化拖拽系列的第三篇,之前的兩篇文章一共對(duì) 17 個(gè)功能點(diǎn)的技術(shù)原理進(jìn)行了分析:

    1.編輯器2.自定義組件3.拖拽4.刪除組件、調(diào)整圖層層級(jí)5.放大縮小6.撤消、重做7.組件屬性設(shè)置8.吸附9.預(yù)覽、保存代碼10.綁定事件11.綁定動(dòng)畫12.導(dǎo)入 PSD13.手機(jī)模式14.拖拽旋轉(zhuǎn)15.復(fù)制粘貼剪切16.數(shù)據(jù)交互17.發(fā)布

    本文在此基礎(chǔ)上,將對(duì)以下幾個(gè)功能點(diǎn)的技術(shù)原理進(jìn)行分析:

    1.多個(gè)組件的組合和拆分2.文本組件3.矩形組件4.鎖定組件5.快捷鍵6.網(wǎng)格線7.編輯器快照的另一種實(shí)現(xiàn)方式

    如果你對(duì)我之前的兩篇文章不是很了解,建議先把這兩篇文章看一遍,再來閱讀此文:

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

    雖然我這個(gè)可視化拖拽組件庫只是一個(gè) DEMO,但對(duì)比了一下市面上的一些現(xiàn)成產(chǎn)品(例如 processon[3]墨刀[4]),就基礎(chǔ)功能來說,我這個(gè) DEMO 實(shí)現(xiàn)了絕大部分的功能。

    如果你對(duì)于低代碼平臺(tái)有興趣,但又不了解的話。強(qiáng)烈建議將我的三篇文章結(jié)合項(xiàng)目源碼一起閱讀,相信對(duì)你的收獲絕對(duì)不小。另附上項(xiàng)目、在線 DEMO 地址:

    ?項(xiàng)目地址[5]?在線 DEMO[6]

    18. 多個(gè)組件的組合和拆分

    組合和拆分的技術(shù)點(diǎn)相對(duì)來說比較多,共有以下 4 個(gè):

    ?選中區(qū)域?組合后的移動(dòng)、旋轉(zhuǎn)?組合后的放大縮小?拆分后子組件樣式的恢復(fù)

    選中區(qū)域

    在將多個(gè)組件組合之前,需要先選中它們。利用鼠標(biāo)事件可以很方便的將選中區(qū)域展示出來:

    1.mousedown 記錄起點(diǎn)坐標(biāo)2.mousemove 將當(dāng)前坐標(biāo)和起點(diǎn)坐標(biāo)進(jìn)行計(jì)算得出移動(dòng)區(qū)域3.如果按下鼠標(biāo)后往左上方移動(dòng),類似于這種操作則需要將當(dāng)前坐標(biāo)設(shè)為起點(diǎn)坐標(biāo),再計(jì)算出移動(dòng)區(qū)域

    // 獲取編輯器的位移信息const rectInfo = this.editor.getBoundingClientRect()this.editorX = rectInfo.xthis.editorY = rectInfo.y
    const startX = e.clientXconst startY = e.clientYthis.start.x = startX - this.editorXthis.start.y = startY - this.editorY// 展示選中區(qū)域this.isShowArea = true
    const move = (moveEvent) => { this.width = Math.abs(moveEvent.clientX - startX) this.height = Math.abs(moveEvent.clientY - startY) if (moveEvent.clientX < startX) { this.start.x = moveEvent.clientX - this.editorX }
    if (moveEvent.clientY < startY) { this.start.y = moveEvent.clientY - this.editorY }}

    在 mouseup 事件觸發(fā)時(shí),需要對(duì)選中區(qū)域內(nèi)的所有組件的位移大小信息進(jìn)行計(jì)算,得出一個(gè)能包含區(qū)域內(nèi)所有組件的最小區(qū)域。這個(gè)效果如下圖所示:

    這個(gè)計(jì)算過程的代碼:

    createGroup() {  // 獲取選中區(qū)域的組件數(shù)據(jù)  const areaData = this.getSelectArea()  if (areaData.length <= 1) {      this.hideArea()      return  }
    // 根據(jù)選中區(qū)域和區(qū)域中每個(gè)組件的位移信息來創(chuàng)建 Group 組件 // 要遍歷選擇區(qū)域的每個(gè)組件,獲取它們的 left top right bottom 信息來進(jìn)行比較 let top = Infinity, left = Infinity let right = -Infinity, bottom = -Infinity areaData.forEach(component => { let style = {} if (component.component == 'Group') { component.propValue.forEach(item => { const rectInfo = $(`#component${item.id}`).getBoundingClientRect() style.left = rectInfo.left - this.editorX style.top = rectInfo.top - this.editorY style.right = rectInfo.right - this.editorX style.bottom = rectInfo.bottom - this.editorY
    if (style.left < left) left = style.left if (style.top < top) top = style.top if (style.right > right) right = style.right if (style.bottom > bottom) bottom = style.bottom }) } else { style = getComponentRotatedStyle(component.style) }
    if (style.left < left) left = style.left if (style.top < top) top = style.top if (style.right > right) right = style.right if (style.bottom > bottom) bottom = style.bottom })
    this.start.x = left this.start.y = top this.width = right - left this.height = bottom - top
    // 設(shè)置選中區(qū)域位移大小信息和區(qū)域內(nèi)的組件數(shù)據(jù) this.$store.commit('setAreaData', { style: { left, top, width: this.width, height: this.height, }, components: areaData, })},
    getSelectArea() { const result = [] // 區(qū)域起點(diǎn)坐標(biāo) const { x, y } = this.start // 計(jì)算所有的組件數(shù)據(jù),判斷是否在選中區(qū)域內(nèi) this.componentData.forEach(component => { if (component.isLock) return const { left, top, width, height } = component.style if (x <= left && y <= top && (left + width <= x + this.width) && (top + height <= y + this.height)) { result.push(component) } })
    // 返回在選中區(qū)域內(nèi)的所有組件 return result}

    簡(jiǎn)單描述一下這段代碼的處理邏輯:

    1.利用 getBoundingClientRect()[7] 瀏覽器 API 獲取每個(gè)組件相對(duì)于瀏覽器視口四個(gè)方向上的信息,也就是 left top right bottom2.對(duì)比每個(gè)組件的這四個(gè)信息,取得選中區(qū)域的最左、最上、最右、最下四個(gè)方向的數(shù)值,從而得出一個(gè)能包含區(qū)域內(nèi)所有組件的最小區(qū)域。3.如果選中區(qū)域內(nèi)已經(jīng)有一個(gè) Group 組合組件,則需要對(duì)它里面的子組件進(jìn)行計(jì)算,而不是對(duì)組合組件進(jìn)行計(jì)算。

    組合后的移動(dòng)、旋轉(zhuǎn)

    為了方便將多個(gè)組件一起進(jìn)行移動(dòng)、旋轉(zhuǎn)、放大縮小等操作,我新創(chuàng)建了一個(gè) Group 組合組件:

    <template>    <div class="group">        <div>             <template v-for="item in propValue">                <component                    class="component"                    :is="item.component"                    :style="item.groupStyle"                    :propValue="item.propValue"                    :key="item.id"                    :id="'component' + item.id"                    :element="item"                />            </template>        </div>    </div></template>
    <script>import { getStyle } from '@/utils/style'
    export default { props: { propValue: { type: Array, default: () => [], }, element: { type: Object, }, }, created() { const parentStyle = this.element.style this.propValue.forEach(component => { // component.groupStyle 的 top left 是相對(duì)于 group 組件的位置 // 如果已存在 component.groupStyle,說明已經(jīng)計(jì)算過一次了。不需要再次計(jì)算 if (!Object.keys(component.groupStyle).length) { const style = { ...component.style } component.groupStyle = getStyle(style) component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width) component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height) component.groupStyle.width = this.toPercent(style.width / parentStyle.width) component.groupStyle.height = this.toPercent(style.height / parentStyle.height) } }) }, methods: { toPercent(val) { return val * 100 + '%' }, },}</script>
    <style lang="scss" scoped>.group { & > div { position: relative; width: 100%; height: 100%;
    .component { position: absolute; } }}</style>

    Group 組件的作用就是將區(qū)域內(nèi)的組件放到它下面,成為子組件。并且在創(chuàng)建 Group 組件時(shí),獲取每個(gè)子組件在 Group 組件內(nèi)的相對(duì)位移和相對(duì)大小:

    created() {    const parentStyle = this.element.style    this.propValue.forEach(component => {        // component.groupStyle 的 top left 是相對(duì)于 group 組件的位置        // 如果已存在 component.groupStyle,說明已經(jīng)計(jì)算過一次了。不需要再次計(jì)算        if (!Object.keys(component.groupStyle).length) {            const style = { ...component.style }            component.groupStyle = getStyle(style)            component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)            component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)            component.groupStyle.width = this.toPercent(style.width / parentStyle.width)            component.groupStyle.height = this.toPercent(style.height / parentStyle.height)        }    })},methods: {        toPercent(val) {            return val * 100 + '%'        },    },

    也就是將子組件的 left top width height 等屬性轉(zhuǎn)成以 % 結(jié)尾的相對(duì)數(shù)值。

    為什么不使用絕對(duì)數(shù)值?

    如果使用絕對(duì)數(shù)值,那么在移動(dòng) Group 組件時(shí),除了對(duì) Group 組件的屬性進(jìn)行計(jì)算外,還需要對(duì)它的每個(gè)子組件進(jìn)行計(jì)算。并且 Group 包含子組件太多的話,在進(jìn)行移動(dòng)、放大縮小時(shí),計(jì)算量會(huì)非常大,有可能會(huì)造成頁面卡頓。如果改成相對(duì)數(shù)值,則只需要在 Group 創(chuàng)建時(shí)計(jì)算一次。然后在 Group 組件進(jìn)行移動(dòng)、旋轉(zhuǎn)時(shí)也不用管 Group 的子組件,只對(duì)它自己計(jì)算即可。

    組合后的放大縮小

    組合后的放大縮小是個(gè)大問題,主要是因?yàn)橛行D(zhuǎn)角度的存在。首先來看一下各個(gè)子組件沒旋轉(zhuǎn)時(shí)的放大縮?。?/p>

    從動(dòng)圖可以看出,效果非常完美。各個(gè)子組件的大小是跟隨 Group 組件的大小而改變的。

    現(xiàn)在試著給子組件加上旋轉(zhuǎn)角度,再看一下效果:

    為什么會(huì)出現(xiàn)這個(gè)問題?

    主要是因?yàn)橐粋€(gè)組件無論旋不旋轉(zhuǎn),它的 top left 屬性都是不變的。這樣就會(huì)有一個(gè)問題,雖然實(shí)際上組件的 top left width height 屬性沒有變化。但在外觀上卻發(fā)生了變化。下面是兩個(gè)同樣的組件:一個(gè)沒旋轉(zhuǎn),一個(gè)旋轉(zhuǎn)了 45 度。

    可以看出來旋轉(zhuǎn)后按鈕的 top left width height 屬性和我們從外觀上看到的是不一樣的。

    接下來再看一個(gè)具體的示例:

    上面是一個(gè) Group 組件,它左邊的子組件屬性為:

    transform: rotate(-75.1967deg);width: 51.2267%;height: 32.2679%;top: 33.8661%;left: -10.6496%;

    可以看到 width 的值為 51.2267%,但從外觀上來看,這個(gè)子組件最多占 Group 組件寬度的三分之一。所以這就是放大縮小不正常的問題所在。

    一個(gè)不可行的解決方案(不想看的可以跳過)

    一開始我想的是,先算出它相對(duì)瀏覽器視口的 top left width height 屬性,再算出這幾個(gè)屬性在 Group 組件上的相對(duì)數(shù)值。這可以通過 getBoundingClientRect() API 實(shí)現(xiàn)。只要維持外觀上的各個(gè)屬性占比不變,這樣 Group 組件在放大縮小時(shí),再通過旋轉(zhuǎn)角度,利用旋轉(zhuǎn)矩陣的知識(shí)(這一點(diǎn)在第二篇有詳細(xì)描述)獲取它未旋轉(zhuǎn)前的 top left width height 屬性。這樣就可以做到子組件動(dòng)態(tài)調(diào)整了。

    但是這有個(gè)問題,通過 getBoundingClientRect() API 只能獲取組件外觀上的 top left right bottom width height 屬性。再加上一個(gè)角度,參數(shù)還是不夠,所以無法計(jì)算出組件實(shí)際的 top left width height 屬性。

    就像上面的這張圖,只知道原點(diǎn) O(x,y) w h 和旋轉(zhuǎn)角度,無法算出按鈕的寬高。

    一個(gè)可行的解決方案

    這是無意中發(fā)現(xiàn)的,我在對(duì) Group 組件進(jìn)行放大縮小時(shí),發(fā)現(xiàn)只要保持 Group 組件的寬高比例,子組件就能做到根據(jù)比例放大縮小。那么現(xiàn)在問題就轉(zhuǎn)變成了如何讓 Group 組件放大縮小時(shí)保持寬高比例。我在網(wǎng)上找到了這一篇文章[8],它詳細(xì)描述了一個(gè)旋轉(zhuǎn)組件如何保持寬高比來進(jìn)行放大縮小,并配有源碼示例。

    現(xiàn)在我嘗試簡(jiǎn)單描述一下如何保持寬高比對(duì)一個(gè)旋轉(zhuǎn)組件進(jìn)行放大縮?。ńㄗh還是看看原文)。下面是一個(gè)已旋轉(zhuǎn)一定角度的矩形,假設(shè)現(xiàn)在拖動(dòng)它左上方的點(diǎn)進(jìn)行拉伸。

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

    // 組件寬高比const proportion = style.width / style.height
    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)的對(duì)稱點(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,}
    // 獲取對(duì)稱點(diǎn)的坐標(biāo)const symmetricPoint = { x: center.x - (curPoint.x - center.x), y: center.y - (curPoint.y - center.y),}

    第三步,摁住組件左上角進(jìn)行拉伸時(shí),通過當(dāng)前鼠標(biāo)實(shí)時(shí)坐標(biāo)和對(duì)稱點(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),即使你知道了拉伸時(shí)移動(dòng)的 xy 距離,也不能直接對(duì)組件進(jìn)行計(jì)算。否則就會(huì)出現(xiàn) BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉(zhuǎn)的情況下對(duì)其進(jìn)行計(jì)算。

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

    對(duì)應(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)矩陣的知識(shí),對(duì)于一個(gè)沒上過大學(xué)的人來說,實(shí)在太難了。還好我從知乎上的一個(gè)回答[9]中找到了這一公式的推理過程,下面是回答的原文:

    通過以上幾個(gè)計(jì)算值,就可以得到組件新的位移值 top left 以及新的組件大小。對(duì)應(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)后的放大縮?。?/p>

    第五步,由于我們現(xiàn)在需要的是鎖定寬高比來進(jìn)行放大縮小,所以需要重新計(jì)算拉伸后的圖形的左上角坐標(biāo)。

    這里先確定好幾個(gè)形狀的命名:

    ?原圖形:  紅色部分?新圖形:  藍(lán)色部分?修正圖形: 綠色部分,即加上寬高比鎖定規(guī)則的修正圖形

    在第四步中算出組件未旋轉(zhuǎn)前的 newTopLeftPoint newBottomRightPoint newWidth newHeight 后,需要根據(jù)寬高比 proportion 來算出新的寬度或高度。

    上圖就是一個(gè)需要改變高度的示例,計(jì)算過程如下:

    if (newWidth / newHeight > proportion) {    newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)    newWidth = newHeight * proportion} else {    newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)    newHeight = newWidth / proportion}

    由于現(xiàn)在求的未旋轉(zhuǎn)前的坐標(biāo)是以沒按比例縮減寬高前的坐標(biāo)來計(jì)算的,所以縮減寬高后,需要按照原來的中心點(diǎn)旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對(duì)應(yīng)的坐標(biāo)。然后以這個(gè)坐標(biāo)和對(duì)稱點(diǎn)獲得新的中心點(diǎn),并重新計(jì)算未旋轉(zhuǎn)前的坐標(biāo)。

    經(jīng)過修改后的完整代碼如下:

    function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {    const { symmetricPoint } = pointInfo    let newCenterPoint = getCenterPoint(curPositon, symmetricPoint)    let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)    let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
    let newWidth = newBottomRightPoint.x - newTopLeftPoint.x let newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (needLockProportion) { if (newWidth / newHeight > proportion) { newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion) newWidth = newHeight * proportion } else { newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion) newHeight = newWidth / proportion }
    // 由于現(xiàn)在求的未旋轉(zhuǎn)前的坐標(biāo)是以沒按比例縮減寬高前的坐標(biāo)來計(jì)算的 // 所以縮減寬高后,需要按照原來的中心點(diǎn)旋轉(zhuǎn)回去,獲得縮減寬高并旋轉(zhuǎn)后對(duì)應(yīng)的坐標(biāo) // 然后以這個(gè)坐標(biāo)和對(duì)稱點(diǎn)獲得新的中心點(diǎn),并重新計(jì)算未旋轉(zhuǎn)前的坐標(biāo) const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate) newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint) newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate) newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
    newWidth = newBottomRightPoint.x - newTopLeftPoint.x 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) }}

    保持寬高比進(jìn)行放大縮小的效果如下:

    當(dāng) Group 組件有旋轉(zhuǎn)的子組件時(shí),才需要保持寬高比進(jìn)行放大縮小。所以在創(chuàng)建 Group 組件時(shí)可以判斷一下子組件是否有旋轉(zhuǎn)角度。如果沒有,就不需要保持寬度比進(jìn)行放大縮小。

    isNeedLockProportion() {    if (this.element.component != 'Group') return false    const ratates = [0, 90, 180, 360]    for (const component of this.element.propValue) {        if (!ratates.includes(mod360(parseInt(component.style.rotate)))) {            return true        }    }
    return false}

    拆分后子組件樣式的恢復(fù)

    將多個(gè)組件組合在一起只是第一步,第二步是將 Group 組件進(jìn)行拆分并恢復(fù)各個(gè)子組件的樣式。保證拆分后的子組件在外觀上的屬性不變。

    計(jì)算代碼如下:

    // storedecompose({ curComponent, editor }) {    const parentStyle = { ...curComponent.style }    const components = curComponent.propValue    const editorRect = editor.getBoundingClientRect()
    store.commit('deleteComponent') components.forEach(component => { decomposeComponent(component, editorRect, parentStyle) store.commit('addComponent', { component }) })}
    // 將組合中的各個(gè)子組件拆分出來,并計(jì)算它們新的 styleexport default function decomposeComponent(component, editorRect, parentStyle) { // 子組件相對(duì)于瀏覽器視口的樣式 const componentRect = $(`#component${component.id}`).getBoundingClientRect() // 獲取元素的中心點(diǎn)坐標(biāo) const center = { x: componentRect.left - editorRect.left + componentRect.width / 2, y: componentRect.top - editorRect.top + componentRect.height / 2, }
    component.style.rotate = mod360(component.style.rotate + parentStyle.rotate) component.style.width = parseFloat(component.groupStyle.width) / 100 * parentStyle.width component.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height // 計(jì)算出元素新的 top left 坐標(biāo) component.style.left = center.x - component.style.width / 2 component.style.top = center.y - component.style.height / 2 component.groupStyle = {}}

    這段代碼的處理邏輯為:

    1.遍歷 Group 的子組件并恢復(fù)它們的樣式2.利用 getBoundingClientRect() API 獲取子組件相對(duì)于瀏覽器視口的 left top width height 屬性。3.利用這四個(gè)屬性計(jì)算出子組件的中心點(diǎn)坐標(biāo)。4.由于子組件的 width height 屬性是相對(duì)于 Group 組件的,所以將它們的百分比值和 Group 相乘得出具體數(shù)值。5.再用中心點(diǎn) center(x, y) 減去子組件寬高的一半得出它的 left top 屬性。

    至此,組合和拆分就講解完了。

    19. 文本組件

    文本組件 VText 之前就已經(jīng)實(shí)現(xiàn)過了,但不完美。例如無法對(duì)文字進(jìn)行選中?,F(xiàn)在我對(duì)它進(jìn)行了重寫,讓它支持選中功能。

    <template>    <div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup">        <!-- tabindex >= 0 使得雙擊時(shí)聚集該元素 -->        <div :contenteditable="canEdit" :class="{ canEdit }" @dblclick="setEdit" :tabindex="element.id" @paste="clearStyle"            @mousedown="handleMousedown" @blur="handleBlur" ref="text" v-html="element.propValue" @input="handleInput"            :style="{ verticalAlign: element.style.verticalAlign }"        ></div>    </div>    <div v-else class="v-text">        <div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"></div>    </div></template>
    <script>import { mapState } from 'vuex'import { keycodes } from '@/utils/shortcutKey.js'
    export default { props: { propValue: { type: String, require: true, }, element: { type: Object, }, }, data() { return { canEdit: false, ctrlKey: 17, isCtrlDown: false, } }, computed: { ...mapState([ 'editMode', ]), }, methods: { handleInput(e) { this.$emit('input', this.element, e.target.innerHTML) },
    handleKeydown(e) { if (e.keyCode == this.ctrlKey) { this.isCtrlDown = true } else if (this.isCtrlDown && this.canEdit && keycodes.includes(e.keyCode)) { e.stopPropagation() } else if (e.keyCode == 46) { // deleteKey e.stopPropagation() } },
    handleKeyup(e) { if (e.keyCode == this.ctrlKey) { this.isCtrlDown = false } },
    handleMousedown(e) { if (this.canEdit) { e.stopPropagation() } },
    clearStyle(e) { e.preventDefault() const clp = e.clipboardData const text = clp.getData('text/plain') || '' if (text !== '') { document.execCommand('insertText', false, text) }
    this.$emit('input', this.element, e.target.innerHTML) },
    handleBlur(e) { this.element.propValue = e.target.innerHTML || ' ' this.canEdit = false },
    setEdit() { this.canEdit = true // 全選 this.selectText(this.$refs.text) },
    selectText(element) { const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(element) selection.removeAllRanges() selection.addRange(range) }, },}</script>
    <style lang="scss" scoped>.v-text { width: 100%; height: 100%; display: table;
    div { display: table-cell; width: 100%; height: 100%; outline: none; }
    .canEdit { cursor: text; height: 100%; }}</style>

    改造后的 VText 組件功能如下:

    1.雙擊啟動(dòng)編輯。2.支持選中文本。3.粘貼時(shí)過濾掉文本的樣式。4.換行時(shí)自動(dòng)擴(kuò)充文本框的高度。

    20. 矩形組件

    矩形組件其實(shí)就是一個(gè)內(nèi)嵌 VText 文本組件的一個(gè) DIV。

    <template>    <div class="rect-shape">        <v-text :propValue="element.propValue" :element="element" />    </div></template>
    <script>export default { props: { element: { type: Object, }, },}</script>
    <style lang="scss" scoped>.rect-shape { width: 100%; height: 100%; overflow: auto;}</style>

    VText 文本組件有的功能它都有,并且可以任意放大縮小。

    21. 鎖定組件

    鎖定組件主要是看到 processon 和墨刀有這個(gè)功能,于是我順便實(shí)現(xiàn)了。鎖定組件的具體需求為:不能移動(dòng)、放大縮小、旋轉(zhuǎn)、復(fù)制、粘貼等,只能進(jìn)行解鎖操作。

    它的實(shí)現(xiàn)原理也不難:

    1.在自定義組件上加一個(gè) isLock 屬性,表示是否鎖定組件。2.在點(diǎn)擊組件時(shí),根據(jù) isLock 是否為 true 來隱藏組件上的八個(gè)點(diǎn)和旋轉(zhuǎn)圖標(biāo)。3.為了突出一個(gè)組件被鎖定,給它加上透明度屬性和一個(gè)鎖的圖標(biāo)。4.如果組件被鎖定,置灰上面所說的需求對(duì)應(yīng)的按鈕,不能被點(diǎn)擊。

    相關(guān)代碼如下:

    export const commonAttr = {    animations: [],    events: {},    groupStyle: {}, // 當(dāng)一個(gè)組件成為 Group 的子組件時(shí)使用    isLock: false, // 是否鎖定組件}
    <el-button @click="decompose" :disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">拆分</el-button>
    <el-button @click="lock" :disabled="!curComponent || curComponent.isLock">鎖定</el-button><el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">解鎖</el-button>
    <template>    <div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }">        <ul @mouseup="handleMouseUp">            <template v-if="curComponent">                <template v-if="!curComponent.isLock">                    <li @click="copy">復(fù)制</li>                    <li @click="paste">粘貼</li>                    <li @click="cut">剪切</li>                    <li @click="deleteComponent">刪除</li>                    <li @click="lock">鎖定</li>                    <li @click="topComponent">置頂</li>                    <li @click="bottomComponent">置底</li>                    <li @click="upComponent">上移</li>                    <li @click="downComponent">下移</li>                </template>                <li v-else @click="unlock">解鎖</li>            </template>            <li v-else @click="paste">粘貼</li>        </ul>    </div></template>

    22. 快捷鍵

    支持快捷鍵主要是為了提升開發(fā)效率,用鼠標(biāo)點(diǎn)點(diǎn)點(diǎn)畢竟沒有按鍵盤快。目前快捷鍵支持的功能如下:

    const ctrlKey = 17,     vKey = 86, // 粘貼    cKey = 67, // 復(fù)制    xKey = 88, // 剪切
    yKey = 89, // 重做 zKey = 90, // 撤銷
    gKey = 71, // 組合 bKey = 66, // 拆分
    lKey = 76, // 鎖定 uKey = 85, // 解鎖
    sKey = 83, // 保存 pKey = 80, // 預(yù)覽 dKey = 68, // 刪除 deleteKey = 46, // 刪除 eKey = 69 // 清空畫布

    實(shí)現(xiàn)原理主要是利用 window 全局監(jiān)聽按鍵事件,在符合條件的按鍵觸發(fā)時(shí)執(zhí)行對(duì)應(yīng)的操作:

    // 與組件狀態(tài)無關(guān)的操作const basemap = {    [vKey]: paste,    [yKey]: redo,    [zKey]: undo,    [sKey]: save,    [pKey]: preview,    [eKey]: clearCanvas,}
    // 組件鎖定狀態(tài)下可以執(zhí)行的操作const lockMap = { ...basemap, [uKey]: unlock,}
    // 組件未鎖定狀態(tài)下可以執(zhí)行的操作const unlockMap = { ...basemap, [cKey]: copy, [xKey]: cut, [gKey]: compose, [bKey]: decompose, [dKey]: deleteComponent, [deleteKey]: deleteComponent, [lKey]: lock,}
    let isCtrlDown = false// 全局監(jiān)聽按鍵操作并執(zhí)行相應(yīng)命令export function listenGlobalKeyDown() { window.onkeydown = (e) => { const { curComponent } = store.state if (e.keyCode == ctrlKey) { isCtrlDown = true } else if (e.keyCode == deleteKey && curComponent) { store.commit('deleteComponent') store.commit('recordSnapshot') } else if (isCtrlDown) { if (!curComponent || !curComponent.isLock) { e.preventDefault() unlockMap[e.keyCode] && unlockMap[e.keyCode]() } else if (curComponent && curComponent.isLock) { e.preventDefault() lockMap[e.keyCode] && lockMap[e.keyCode]() } } }
    window.onkeyup = (e) => { if (e.keyCode == ctrlKey) { isCtrlDown = false } }}

    為了防止和瀏覽器默認(rèn)快捷鍵沖突,所以需要加上 e.preventDefault()

    23. 網(wǎng)格線

    網(wǎng)格線功能使用 SVG 來實(shí)現(xiàn):

    <template>    <svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">        <defs>            <pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse">                <path                     d="M 7.236328125 0 L 0 0 0 7.236328125"                     fill="none"                     stroke="rgba(207, 207, 207, 0.3)"                     stroke-width="1">                </path>            </pattern>            <pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse">                <rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"></rect>                <path                     d="M 36.181640625 0 L 0 0 0 36.181640625"                     fill="none"                     stroke="rgba(186, 186, 186, 0.5)"                     stroke-width="1">                </path>            </pattern>        </defs>        <rect width="100%" height="100%" fill="url(#grid)"></rect>    </svg></template>
    <style lang="scss" scoped>.grid { position: absolute; top: 0; left: 0;}</style>

    對(duì) SVG 不太懂的,建議看一下 MDN 的教程[10]。

    24. 編輯器快照的另一種實(shí)現(xiàn)方式

    在系列文章的第一篇中,我已經(jīng)分析過快照的實(shí)現(xiàn)原理。

    snapshotData: [], // 編輯器快照數(shù)據(jù)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 過程中,添加新的快照時(shí),要將它后面的快照清理掉 if (state.snapshotIndex < state.snapshotData.length - 1) { state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1) }},

    用一個(gè)數(shù)組來保存編輯器的快照數(shù)據(jù)。保存快照就是不停地執(zhí)行 push() 操作,將當(dāng)前的編輯器數(shù)據(jù)推入 snapshotData 數(shù)組,并增加快照索引 snapshotIndex

    由于每一次添加快照都是將當(dāng)前編輯器的所有組件數(shù)據(jù)推入 snapshotData,保存的快照數(shù)據(jù)越多占用的內(nèi)存就越多。對(duì)此有兩個(gè)解決方案:

    1.限制快照步數(shù),例如只能保存 50 步的快照數(shù)據(jù)。2.保存快照只保存差異部分。

    現(xiàn)在詳細(xì)描述一下第二個(gè)解決方案

    假設(shè)依次往畫布上添加 a b c d 四個(gè)組件,在原來的實(shí)現(xiàn)中,對(duì)應(yīng)的 snapshotData 數(shù)據(jù)為:

    // snapshotData[  [a],  [a, b],  [a, b, c],  [a, b, c, d],]

    從上面的代碼可以發(fā)現(xiàn),每一相鄰的快照中,只有一個(gè)數(shù)據(jù)是不同的。所以我們可以為每一步的快照添加一個(gè)類型字段,用來表示此次操作是添加還是刪除。

    那么上面添加四個(gè)組件的操作,所對(duì)應(yīng)的 snapshotData 數(shù)據(jù)為:

    // snapshotData[  [{ type: 'add', value: a }],  [{ type: 'add', value: b }],  [{ type: 'add', value: c }],  [{ type: 'add', value: d }],]

    如果我們要?jiǎng)h除 c 組件,那么 snapshotData 數(shù)據(jù)將變?yōu)椋?/p>

    // snapshotData[  [{ type: 'add', value: a }],  [{ type: 'add', value: b }],  [{ type: 'add', value: c }],  [{ type: 'add', value: d }],  [{ type: 'remove', value: c }],]

    那如何使用現(xiàn)在的快照數(shù)據(jù)呢

    我們需要遍歷一遍快照數(shù)據(jù),來生成編輯器的組件數(shù)據(jù) componentData。假設(shè)在上面的數(shù)據(jù)基礎(chǔ)上執(zhí)行了 undo 撤銷操作:

    // snapshotData// 快照索引 snapshotIndex 此時(shí)為 3[  [{ type: 'add', value: a }],  [{ type: 'add', value: b }],  [{ type: 'add', value: c }],  [{ type: 'add', value: d }],  [{ type: 'remove', value: c }],]

    1.snapshotData[0] 類型為 add,將組件 a 添加到 componentData 中,此時(shí) componentData 為 [a]2.依次類推 [a, b]3.[a, b, c]4.[a, b, c, d]

    如果這時(shí)執(zhí)行 redo 重做操作,快照索引 snapshotIndex 變?yōu)?4。對(duì)應(yīng)的快照數(shù)據(jù)類型為 type: 'remove', 移除組件 c。則數(shù)組數(shù)據(jù)為 [a, b, d]

    這種方法其實(shí)就是時(shí)間換空間,雖然每一次保存的快照數(shù)據(jù)只有一項(xiàng),但每次都得遍歷一遍所有的快照數(shù)據(jù)。兩種方法都不完美,要使用哪種取決于你,目前我仍在使用第一種方法。

    總結(jié)

    從造輪子的角度來看,這是我目前造的第四個(gè)比較滿意的輪子,其他三個(gè)為:

    ?nand2tetris[11]?MIT6.828[12]?mini-vue[13]

    造輪子是一個(gè)很好的提升自己技術(shù)水平的方法,但造輪子一定要造有意義、有難度的輪子,并且同類型的輪子只造一個(gè)。造完輪子后,還需要寫總結(jié),最好輸出成文章分享出去。

    參考資料

    ?snapping-demo[14]?processon[15]?墨刀[16]

    References

    [1] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析: https://juejin.cn/post/6908502083075325959
    [2] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(二): https://juejin.cn/post/6918881497264947207
    [3] processon: https://www.processon.com/
    [4] 墨刀: https://modao.cc/
    [5] 項(xiàng)目地址: https://github.com/woai3c/visual-drag-demo
    [6] 在線 DEMO: https://woai3c.gitee.io/visual-drag-demo
    [7] getBoundingClientRect(): https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
    [8] 文章: https://github.com/shenhudong/snapping-demo/wiki/corner-handle
    [9] 回答: https://www.zhihu.com/question/67425734/answer/252724399
    [10] 教程: https://developer.mozilla.org/zh-CN/docs/Web/SVG
    [11] nand2tetris: https://github.com/woai3c/nand2tetris
    [12] MIT6.828: https://github.com/woai3c/MIT6.828
    [13] mini-vue: https://github.com/woai3c/mini-vue
    [14] snapping-demo: https://github.com/shenhudong/snapping-demo/wiki/corner-handle
    [15] processon: https://www.processon.com/
    [16] 墨刀: https://modao.cc/


    瀏覽 96
    點(diǎn)贊
    評(píng)論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報(bào)
    評(píng)論
    圖片
    表情
    推薦
    點(diǎn)贊
    評(píng)論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報(bào)

    <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>
    亚洲第一中文字幕 | 中文字幕一区二区三区免费2023 | 中文毛片| 女人18毛片A级毛片 | 俺也色俺也干 |