<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ 可视化 - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ freeCodeCamp 是一个免费学习编程的开发者社区，涵盖 Python、HTML、CSS、React、Vue、BootStrap、JSON 教程等，还有活跃的技术论坛和丰富的社区活动，在你学习编程和找工作时为你提供建议和帮助。 ]]>
        </description>
        <link>https://www.freecodecamp.org/chinese/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ 可视化 - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/chinese/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 19:38:02 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/chinese/news/tag/visualization/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ 可视化拖拽组件库一些技术要点原理分析（四） ]]>
                </title>
                <description>
                    <![CDATA[ 本文是可视化拖拽系列的第四篇，比起之前的三篇文章，这篇功能点要稍微少一点，总共有五点：  1. SVG 组件  2. 动态属性面板  3. 数据来源（接口请求）  4. 组件联动  5. 组件按需加载 如果你对我之前的系列文章不是很了解，建议先把这三篇文章看一遍，再来阅读本文（否则没有上下文，不太好理解）：  * 可视化拖拽组件库一些技术要点原理分析（一）    [https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/]  * 可视化拖拽组件库一些技术要点原理分析（二）    [https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-2/]  * 可视化拖拽组件库一些技术要点原理分析（三）    [https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-3/] 另附上 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-4/</link>
                <guid isPermaLink="false">62f9c8df60480505ded7a1bc</guid>
                
                    <category>
                        <![CDATA[ 可视化 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 04 May 2021 12:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2022/08/46985502492_aa7a296b6b_b.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文是可视化拖拽系列的第四篇，比起之前的三篇文章，这篇功能点要稍微少一点，总共有五点：</p><ol><li>SVG 组件</li><li>动态属性面板</li><li>数据来源（接口请求）</li><li>组件联动</li><li>组件按需加载</li></ol><p>如果你对我之前的系列文章不是很了解，建议先把这三篇文章看一遍，再来阅读本文（否则没有上下文，不太好理解）：</p><ul><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/">可视化拖拽组件库一些技术要点原理分析（一）</a></li><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-2/">可视化拖拽组件库一些技术要点原理分析（二）</a></li><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-3/">可视化拖拽组件库一些技术要点原理分析（三）</a></li></ul><p>另附上项目、在线 DEMO 地址：</p><ul><li><a href="https://github.com/woai3c/visual-drag-demo">一个低代码（可视化拖拽）教学项目</a></li><li><a href="https://woai3c.github.io/visual-drag-demo/">在线 DEMO</a></li></ul><h2 id="svg-">SVG 组件</h2><p>目前项目里提供的自定义组件都是支持自由放大缩小的，不过他们有一个共同点——都是规则形状。也就是说对它们放大缩小，直接改变宽高就可以实现了，无需做其他处理。但是不规则形状就不一样了，譬如一个五角星，你得考虑放大缩小时，如何成比例的改变尺寸。最终，我采用了 svg 的方案来实现（还考虑过用 iconfont 来实现，不过有缺陷，放弃了），下面让我们来看看具体的实现细节。</p><h3 id="-svg-">用 SVG 画一个五角星</h3><p>假设我们需要画一个 100 * 100 的五角星，它的代码是这样的：</p><pre><code class="language-html">&lt;svg 
    version="1.1" 
    baseProfile="full" 
    xmlns="http://www.w3.org/2000/svg"
&gt;
    &lt;polygon 
        points="50 0,62.5 37.5,100 37.5,75 62.5,87.5 100,50 75,12.5 100,25 62.5,0 37.5,37.5 37.5" 
        stroke="#000" 
        fill="rgba(255, 255, 255, 1)" 
        stroke-width="1"
    &gt;&lt;/polygon&gt;
&lt;/svg&gt;
复制代码</code></pre><p>svg 上的版本、命名空间之类的属性不是很重要，可以先忽略。重点是 polygon 这个元素，它在 svg 中定义了一个由<code>一组首尾相连的直线线段构成的闭合多边形形状</code>，最后一点连接到第一点。也就是说这个多边形由一系列坐标点组成，相连的点之间会自动连上。polygon 的 points 属性用来表示多边形的一系列坐标点，每个坐标点由 x y 坐标组成，每个坐标点之间用 <code>,</code>逗号分隔。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/1.png" class="kg-image" alt="1" width="600" height="400" loading="lazy"></figure><p>上图就是一个用 svg 画的五角星，它由十个坐标点组成 <code>50 0,62.5 37.5,100 37.5,75 62.5,87.5 100,50 75,12.5 100,25 62.5,0 37.5,37.5 37.5</code>。由于这是一个 100*100 的五角星，所以我们能够很容易的根据每个坐标点的数值算出它们在五角星（坐标系）中所占的比例。譬如第一个点是 p1(<code>50,0</code>)，那么它的 x y 坐标比例是 <code>50%, 0</code>；第二个点 p2(<code>62.5,37.5</code>)，对应的比例是 <code>62.5%, 37.5%</code>...</p><pre><code class="language-js">// 五角星十个坐标点的比例集合
const points = [
    [0.5, 0],
    [0.625, 0.375],
    [1, 0.375],
    [0.75, 0.625],
    [0.875, 1],
    [0.5, 0.75],
    [0.125, 1],
    [0.25, 0.625],
    [0, 0.375],
    [0.375, 0.375],
]
复制代码</code></pre><p>既然知道了五角星的比例，那么要画出其他尺寸的五角星也就易如反掌了。我们只需要在每次对五角星进行放大缩小，改变它的尺寸时，等比例的给出每个坐标点的具体数值即要。</p><pre><code class="language-html">&lt;div class="svg-star-container"&gt;
    &lt;svg
        version="1.1"
        baseProfile="full"
        xmlns="http://www.w3.org/2000/svg"
    &gt;
        &lt;polygon
            ref="star"
            :points="points"
            :stroke="element.style.borderColor"
            :fill="element.style.backgroundColor"
            stroke-width="1"
        /&gt;
    &lt;/svg&gt;
    &lt;v-text :prop-value="element.propValue" :element="element" /&gt;
&lt;/div&gt;

&lt;script&gt;
function drawPolygon(width, height) {
    // 五角星十个坐标点的比例集合
    const points = [
        [0.5, 0],
        [0.625, 0.375],
        [1, 0.375],
        [0.75, 0.625],
        [0.875, 1],
        [0.5, 0.75],
        [0.125, 1],
        [0.25, 0.625],
        [0, 0.375],
        [0.375, 0.375],
    ]

    const coordinatePoints = points.map(point =&gt; width * point[0] + ' ' + height * point[1])
    this.points = coordinatePoints.toString() // 得出五角星的 points 属性数据
}
&lt;/script&gt;
复制代码</code></pre><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/2.gif" class="kg-image" alt="2" width="600" height="400" loading="lazy"></figure><h3 id="-svg--1">其他 SVG 组件</h3><p>同理，要画其他类型的 svg 组件，我们只要知道它们坐标点所占的比例就可以了。如果你不知道一个 svg 怎么画，可以网上搜一下，先找一个能用的 svg 代码（这个五角星的 svg 代码，就是在网上找的）。然后再计算它们每个坐标点所占的比例，转成小数点的形式，最后把这些数据代入上面提供的 <code>drawPolygon()</code> 函数即可。譬如画一个三角形的代码是这样的：</p><pre><code class="language-js">function drawTriangle(width, height) {
    const points = [
        [0.5, 0.05],
        [1, 0.95],
        [0, 0.95],
    ]

    const coordinatePoints = points.map(point =&gt; width * point[0] + ' ' + height * point[1])
    this.points = coordinatePoints.toString() // 得出三角形的 points 属性数据
}
复制代码</code></pre><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/3.png" class="kg-image" alt="3" width="600" height="400" loading="lazy"></figure><h2 id="-">动态属性面板</h2><p>目前所有自定义组件的属性面板都共用同一个 AttrList 组件。因此弊端很明显，需要在这里写很多 if 语句，因为不同的组件有不同的属性。例如矩形组件有 content 属性，但是图片没有，一个不同的属性就得写一个 if 语句。</p><pre><code class="language-html">&lt;el-form-item v-if="name === 'rectShape'" label="内容"&gt;
   &lt;el-input /&gt;
&lt;/el-form-item&gt;
&lt;!-- 其他属性... --&gt;
复制代码</code></pre><p>幸好，这个问题的解决方案也不难。在本系列的第一篇文章中，有讲解过如何动态渲染自定义组件：</p><pre><code class="language-html">&lt;component :is="item.component"&gt;&lt;/component&gt; &lt;!-- 动态渲染组件 --&gt;
复制代码</code></pre><p>在每个自定义组件的数据结构中都有一个 <code>component</code> 属性，这是该组件在 Vue 中注册的名称。因此，每个自定义组件的属性面板可以和组件本身一样（利用 <code>component</code> 属性），做成动态的：</p><pre><code class="language-html">&lt;!-- 右侧属性列表 --&gt;
&lt;section class="right"&gt;
    &lt;el-tabs v-if="curComponent" v-model="activeName"&gt;
        &lt;el-tab-pane label="属性" name="attr"&gt;
            &lt;component :is="curComponent.component + 'Attr'" /&gt; &lt;!-- 动态渲染属性面板 --&gt;
        &lt;/el-tab-pane&gt;
        &lt;el-tab-pane label="动画" name="animation" style="padding-top: 20px;"&gt;
            &lt;AnimationList /&gt;
        &lt;/el-tab-pane&gt;
        &lt;el-tab-pane label="事件" name="events" style="padding-top: 20px;"&gt;
            &lt;EventList /&gt;
        &lt;/el-tab-pane&gt;
    &lt;/el-tabs&gt;
    &lt;CanvasAttr v-else&gt;&lt;/CanvasAttr&gt;
&lt;/section&gt;
复制代码</code></pre><p>同时，自定义组件的目录结构也需要做下调整，原来的目录结构为：</p><pre><code class="language-diff">- VText.vue
- Picture.vue
...
复制代码</code></pre><p>调整后变为：</p><pre><code class="language-html">- VText
	- Attr.vue &lt;!-- 组件的属性面板 --&gt;
	- Component.vue &lt;!-- 组件本身 --&gt;
- Picture
	- Attr.vue
	- Component.vue
复制代码</code></pre><p>现在每一个组件都包含了组件本身和它的属性面板。经过改造后，图片属性面板代码也更加精简了：</p><pre><code class="language-html">&lt;template&gt;
    &lt;div class="attr-list"&gt;
        &lt;CommonAttr&gt;&lt;/CommonAttr&gt; &lt;!-- 通用属性 --&gt;
        &lt;el-form&gt;
            &lt;el-form-item label="镜像翻转"&gt;
                &lt;div style="clear: both;"&gt;
                    &lt;el-checkbox v-model="curComponent.propValue.flip.horizontal" label="horizontal"&gt;水平翻转&lt;/el-checkbox&gt;
                    &lt;el-checkbox v-model="curComponent.propValue.flip.vertical" label="vertical"&gt;垂直翻转&lt;/el-checkbox&gt;
                &lt;/div&gt;
            &lt;/el-form-item&gt;
        &lt;/el-form&gt;
    &lt;/div&gt;
&lt;/template&gt;
复制代码</code></pre><p>这样一来，组件和对应的属性面板都变成动态的了。以后需要单独给某个自定义组件添加属性就非常方便了。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/4.gif" class="kg-image" alt="4" width="600" height="400" loading="lazy"></figure><h2 id="--1">数据来源（接口请求）</h2><p>有些组件会有动态加载数据的需求，所以特地加了一个 <code>Request</code> 公共属性组件，用于请求数据。当一个自定义组件拥有 <code>request</code> 属性时，就会在属性面板上渲染接口请求的相关内容。至此，属性面板的公共组件已经有两个了：</p><pre><code class="language-html">-common
	- Request.vue &lt;!-- 接口请求 --&gt;
	- CommonAttr.vue &lt;!-- 通用样式 --&gt;
复制代码</code></pre><pre><code class="language-js">// VText 自定义组件的数据结构
{
    component: 'VText',
    label: '文字',
    propValue: '双击编辑文字',
    icon: 'wenben',
    request: { // 接口请求
        method: 'GET',
        data: [],
        url: '',
        series: false, // 是否定时发送请求
        time: 1000, // 定时更新时间
        paramType: '', // string object array
        requestCount: 0, // 请求次数限制，0 为无限
    },
    style: { // 通用样式
        width: 200,
        height: 28,
        fontSize: '',
        fontWeight: 400,
        lineHeight: '',
        letterSpacing: 0,
        textAlign: '',
        color: '',
    },
}
复制代码</code></pre><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/5.gif" class="kg-image" alt="5" width="600" height="400" loading="lazy"></figure><p>从上面的动图可以看出，api 请求的方法参数等都是可以手动修改的。但是怎么控制返回来的数据赋值给组件的某个属性呢？这可以在发出请求的时候把组件的整个数据对象 <code>obj</code> 以及要修改属性的 <code>key</code> 当成参数一起传进去，当数据返回来时，就可以直接使用 <code>obj[key] = data</code> 来修改数据了。</p><pre><code class="language-js">// 第二个参数是要修改数据的父对象，第三个参数是修改数据的 key，第四个数据修改数据的类型
this.cancelRequest = request(this.request, this.element, 'propValue', 'string')
复制代码</code></pre><h2 id="--2">组件联动</h2><p>组件联动：当一个组件触发事件时，另一个组件会收到通知，并且做出相应的操作。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/6.gif" class="kg-image" alt="6" width="600" height="400" loading="lazy"></figure><p>上面这个动图的矩形，它分别监听了下面两个按钮的悬浮事件，第一个按钮触发悬浮并广播事件，矩形执行回调向右旋转移动；第二个按钮则相反，向左旋转移动。</p><p>要实现这个功能，首先要给自定义组件加一个新属性 <code>linkage</code>，用来记录所有要联动的组件：</p><pre><code class="language-js">{
	// 组件的其他属性...
	linkage: {
	     duration: 0, // 过渡持续时间
	     data: [ // 组件联动
	         {
	             id: '', // 联动的组件 id
	             label: '', // 联动的组件名称
	             event: '', // 监听事件
	             style: [{ key: '', value: '' }], // 监听的事件触发时，需要改变的属性
	         },
	     ],
	 }
}
复制代码</code></pre><p>对应的属性面板为：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/7.png" class="kg-image" alt="7" width="600" height="400" loading="lazy"></figure><p>组件联动本质上就是订阅/发布模式的运用，每个组件在渲染时都会遍历它监听的所有组件。</p><h3 id="--3">事件监听</h3><pre><code class="language-html">&lt;script&gt;
import eventBus from '@/utils/eventBus'

export default {
    props: {
        linkage: {
            type: Object,
            default: () =&gt; {},
        },
        element: {
            type: Object,
            default: () =&gt; {},
        },
    },
    created() {
        if (this.linkage?.data?.length) {
            eventBus.$on('v-click', this.onClick)
            eventBus.$on('v-hover', this.onHover)
        }
    },
    mounted() {
        const { data, duration } = this.linkage || {}
        if (data?.length) {
            this.$el.style.transition = `all ${duration}s`
        }
    },
    beforeDestroy() {
        if (this.linkage?.data?.length) {
            eventBus.$off('v-click', this.onClick)
            eventBus.$off('v-hover', this.onHover)
        }
    },
    methods: {
        changeStyle(data = []) {
            data.forEach(item =&gt; {
                item.style.forEach(e =&gt; {
                    if (e.key) {
                        this.element.style[e.key] = e.value
                    }
                })
            })
        },

        onClick(componentId) {
            const data = this.linkage.data.filter(item =&gt; item.id === componentId &amp;&amp; item.event === 'v-click')
            this.changeStyle(data)
        },

        onHover(componentId) {
            const data = this.linkage.data.filter(item =&gt; item.id === componentId &amp;&amp; item.event === 'v-hover')
            this.changeStyle(data)
        },
    },
}
&lt;/script&gt;
复制代码</code></pre><p>从上述代码可以看出：</p><ol><li>每一个自定义组件初始化时，都会监听 <code>v-click</code> <code>v-hover</code> 两个事件（目前只有点击、悬浮两个事件）</li><li>事件回调函数触发时会收到一个参数——发出事件的组件 id（譬如多个组件都触发了点击事件，需要根据 id 来判断是否是自己监听的组件）</li><li>最后再修改对应的属性</li></ol><h3 id="--4">事件触发</h3><pre><code class="language-html">&lt;template&gt;
    &lt;div @click="onClick" @mouseenter="onMouseEnter"&gt;
        &lt;component
            :is="config.component"
            ref="component"
            class="component"
            :style="getStyle(config.style)"
            :prop-value="config.propValue"
            :element="config"
            :request="config.request"
            :linkage="config.linkage"
        /&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
import eventBus from '@/utils/eventBus'

export default {
    methods: {
        onClick() {
            const events = this.config.events
            Object.keys(events).forEach(event =&gt; {
                this[event](events[event])
            })

            eventBus.$emit('v-click', this.config.id)
        },

        onMouseEnter() {
            eventBus.$emit('v-hover', this.config.id)
        },
    },
}
&lt;/script&gt;
复制代码</code></pre><p>从上述代码可以看出，在渲染组件时，每一个组件的最外层都监听了 <code>click</code> <code>mouseenter</code> 事件，当这些事件触发时，eventBus 就会触发对应的事件（ v-click 或 v-hover ），并且把当前的组件 id 作为参数传过去。</p><p>最后再捊一遍整体逻辑：</p><ol><li>a 组件监听原生事件 click mouseenter</li><li>用户点击或移动鼠标到组件上触发原生事件 click 或 mouseenter</li><li>事件回调函数再用 eventBus 触发 v-click 或 v-hover 事件</li><li>监听了这两个事件的 b 组件收到通知后再修改 b 组件的相关属性（例如上面矩形的 x &nbsp;坐标和旋转角度）</li></ol><h2 id="--5">组件按需加载</h2><p>目前这个项目本身是没有做按需加载的，但是我把实现方案用文字的形式写出来其实也差不多。</p><h4 id="--6">第一步，抽离</h4><p>第一步需要把所有的自定义组件出离出来，单独存放。建议使用 monorepo 的方式来存放，所有的组件放在一个仓库里。每一个 package 就是一个组件，可以单独打包。</p><pre><code class="language-bash">- node_modules
- packages
	- v-text # 一个组件就是一个包 
	- v-button
	- v-table
- package.json
- lerna.json
复制代码</code></pre><h4 id="--7">第二步，打包</h4><p>建议每个组件都打包成一个 js 文件 ，例如叫 bundle.js。打包好直接调用上传接口放到服务器存起来（发布到 npm 也可以），每个组件都有一个唯一 id。前端每次渲染组件的时，通过这个组件 id 向服务器请求组件资源的 URL。</p><h4 id="--8">第三步，动态加载组件</h4><p>动态加载组件有两种方式：</p><ol><li><code>import()</code></li><li><code>&lt;script&gt;</code> 标签</li></ol><p>第一种方式实现起来比较方便：</p><pre><code class="language-js">const name = 'v-text' // 组件名称
const component = await import('https://xxx.xxx/bundile.js')
Vue.component(name, component)
复制代码</code></pre><p>但是兼容性上有点小问题，如果要支持一些旧的浏览器（例如 IE），可以使用 <code>&lt;script&gt;</code> 标签的形式来加载：</p><pre><code class="language-js">function loadjs(url) {
    return new Promise((resolve, reject) =&gt; {
        const script = document.createElement('script')
        script.src = url
        script.onload = resolve
        script.onerror = reject
    })
}

const name = 'v-text' // 组件名称
await loadjs('https://xxx.xxx/bundile.js')
// 这种方式加载组件，会直接将组件挂载在全局变量 window 下，所以 window[name] 取值后就是组件
Vue.component(name, window[name])
复制代码</code></pre><p>为了同时支持这两种加载方式，在加载组件时需要判断一下浏览器是否支持 ES6。如果支持就用第一种方式，如果不支持就用第二种方式：</p><pre><code class="language-js">function isSupportES6() {
    try {
        new Function('const fn = () =&gt; {};')
    } catch (error) {
        return false
    }

    return true
}
复制代码</code></pre><p>最后一点，打包也要同时兼容这两种加载方式：</p><pre><code class="language-js">import VText from './VText.vue'

if (typeof window !== 'undefined') {
    window['VText'] = VText
}

export default VText
复制代码</code></pre><p>同时导出组件和把组件挂在 window 下。</p><h2 id="--9">其他小优化</h2><h3 id="--10">图片镜像翻转</h3><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/8.gif" class="kg-image" alt="8" width="600" height="400" loading="lazy"></figure><p>图片镜像翻转需要使用 canvas 来实现，主要使用的是 canvas 的 <code>translate()</code> <code>scale()</code> 两个方法。假设我们要对一个 100*100 的图片进行水平镜像翻转，它的代码是这样的：</p><pre><code class="language-html">&lt;canvas width="100" height="100"&gt;&lt;/canvas&gt;

&lt;script&gt;
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const img = document.createElement('img')
    const width = 100
    const height = 100
    img.src = 'https://avatars.githubusercontent.com/u/22117876?v=4'
    img.onload = () =&gt; ctx.drawImage(img, 0, 0, width, height)

    // 水平翻转
    setTimeout(() =&gt; {
        // 清除图片
        ctx.clearRect(0, 0, width, height)
        // 平移图片
        ctx.translate(width, 0)
        // 对称镜像
        ctx.scale(-1, 1)
        ctx.drawImage(img, 0, 0, width, height)
        // 还原坐标点
        ctx.setTransform(1, 0, 0, 1, 0, 0)
    }, 2000)
&lt;/script&gt;
复制代码</code></pre><p><code>ctx.translate(width, 0)</code> 这行代码的意思是把图片的 x 坐标往前移动 width 个像素，所以平移后，图片就刚好在画布外面。然后这时使用 <code>ctx.scale(-1, 1)</code> 对图片进行水平翻转，就能得到一个水平翻转后的图片了。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/9.gif" class="kg-image" alt="9" width="600" height="400" loading="lazy"></figure><p>垂直翻转也是一样的原理，只不过参数不一样：</p><pre><code class="language-js">// 原来水平翻转是 ctx.translate(width, 0)
ctx.translate(0, height) 
// 原来水平翻转是 ctx.scale(-1, 1)
ctx.scale(1, -1)
复制代码</code></pre><h3 id="--11">实时组件列表</h3><p>画布中的每一个组件都是有层级的，但是每个组件的具体层级并不会实时显现出来。因此，就有了这个实时组件列表的功能。</p><p>这个功能实现起来并不难，它的原理和画布渲染组件是一样的，只不过这个列表只需要渲染图标和名称。</p><pre><code class="language-html">&lt;div class="real-time-component-list"&gt;
    &lt;div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: index === curComponentIndex }"
        @click="onClick(index)"
    &gt;
        &lt;span class="iconfont" :class="'icon-' + getComponent(index).icon"&gt;&lt;/span&gt;
        &lt;span&gt;{{ getComponent(index).label }}&lt;/span&gt;
    &lt;/div&gt;
&lt;/div&gt;
复制代码</code></pre><p>但是有一点要注意，在组件数据的数组里，越靠后的组件层级越高。所以不对数组的数据索引做处理的话，用户看到的场景是这样的（<strong>假设添加组件的顺序为文本、按钮、图片</strong>）：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/10.png" class="kg-image" alt="10" width="600" height="400" loading="lazy"></figure><p>从用户的角度来看，层级最高的图片，在实时列表里排在最后。这跟我们平时的认知不太一样。所以，我们需要对组件数据做个 <code>reverse()</code> 翻转一下。譬如文字组件的索引为 0，层级最低，它应该显示在底部。那么每次在实时列表展示时，我们可以通过下面的代码转换一下，得到翻转后的索引，然后再渲染，这样的排序看起来就比较舒服了。</p><pre><code class="language-html">&lt;div class="real-time-component-list"&gt;
    &lt;div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: transformIndex(index) === curComponentIndex }"
        @click="onClick(transformIndex(index))"
    &gt;
        &lt;span class="iconfont" :class="'icon-' + getComponent(index).icon"&gt;&lt;/span&gt;
        &lt;span&gt;{{ getComponent(index).label }}&lt;/span&gt;
    &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
function getComponent(index) {
    return componentData[componentData.length - 1 - index]
}

function transformIndex(index) {
    return componentData.length - 1 - index
}
&lt;/script&gt;
复制代码</code></pre><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2022/08/11.png" class="kg-image" alt="11" width="600" height="400" loading="lazy"></figure><p>经过转换后，层级最高的图片在实时列表里排在最上面，完美！</p><h2 id="--12">总结</h2><p>至此，可视化拖拽系列的第四篇文章已经结束了，距离上一篇系列文章的发布时间已经有一年多了。没想到这个项目这么受欢迎，在短短一年的时间里获得了很多网友的认可。所以希望本系列的第四篇文章还是能像之前一样，对大家有帮助，再次感谢！</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 可视化拖拽组件库一些技术要点原理分析（三） ]]>
                </title>
                <description>
                    <![CDATA[ 本文是可视化拖拽系列的第三篇，之前的两篇文章一共对 17 个功能点的技术原理进行了分析：  1.  编辑器  2.  自定义组件  3.  拖拽  4.  删除组件、调整图层层级  5.  放大缩小  6.  撤消、重做  7.  组件属性设置  8.  吸附  9.  预览、保存代码  10. 绑定事件  11. 绑定动画  12. 导入 PSD  ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-3/</link>
                <guid isPermaLink="false">6029e1596183a705401568a1</guid>
                
                    <category>
                        <![CDATA[ 可视化 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 组件 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Thu, 11 Feb 2021 08:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/02/domenico-loia-EhTcC9sYXsw-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文是可视化拖拽系列的第三篇，之前的两篇文章一共对 17 个功能点的技术原理进行了分析：</p><ol><li>编辑器</li><li>自定义组件</li><li>拖拽</li><li>删除组件、调整图层层级</li><li>放大缩小</li><li>撤消、重做</li><li>组件属性设置</li><li>吸附</li><li>预览、保存代码</li><li>绑定事件</li><li>绑定动画</li><li>导入 PSD</li><li>手机模式</li><li>拖拽旋转</li><li>复制粘贴剪切</li><li>数据交互</li><li>发布</li></ol><p>本文在此基础上，将对以下几个功能点的技术原理进行分析：</p><ol><li>多个组件的组合和拆分</li><li>文本组件</li><li>矩形组件</li><li>锁定组件</li><li>快捷键</li><li>网格线</li><li>编辑器快照的另一种实现方式</li></ol><p>如果你对我之前的两篇文章不是很了解，建议先把这两篇文章看一遍，再来阅读此文：</p><ul><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/">可视化拖拽组件库一些技术要点原理分析</a></li><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-2/">可视化拖拽组件库一些技术要点原理分析（二）</a></li></ul><p>虽然我这个可视化拖拽组件库只是一个 DEMO，但对比了一下市面上的一些现成产品（例如 <a href="https://www.processon.com/">processon</a>、<a href="https://modao.cc/">墨刀</a>），就基础功能来说，我这个 DEMO 实现了绝大部分的功能。</p><p>如果你对于低代码平台有兴趣，但又不了解的话。强烈建议将我的三篇文章结合项目源码一起阅读，相信对你的收获绝对不小。另附上项目、在线 DEMO 地址：</p><ul><li><a href="https://github.com/woai3c/visual-drag-demo">项目地址</a></li><li><a href="https://woai3c.gitee.io/visual-drag-demo">在线 DEMO</a></li></ul><h2 id="18-"><strong>18. 多个组件的组合和拆分</strong></h2><p>组合和拆分的技术点相对来说比较多，共有以下 4 个：</p><ul><li>选中区域</li><li>组合后的移动、旋转</li><li>组合后的放大缩小</li><li>拆分后子组件样式的恢复</li></ul><h3 id="-"><strong>选中区域</strong></h3><p>在将多个组件组合之前，需要先选中它们。利用鼠标事件可以很方便的将选中区域展示出来：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/1e981b2ce65f9ee65937688a34dd0a0a.gif" class="kg-image" alt="1e981b2ce65f9ee65937688a34dd0a0a" width="600" height="400" loading="lazy"></figure><ol><li><code>mousedown</code> 记录起点坐标</li><li><code>mousemove</code> 将当前坐标和起点坐标进行计算得出移动区域</li><li>如果按下鼠标后往左上方移动，类似于这种操作则需要将当前坐标设为起点坐标，再计算出移动区域</li></ol><pre><code class="language-js">// 获取编辑器的位移信息
const rectInfo = this.editor.getBoundingClientRect()
this.editorX = rectInfo.x
this.editorY = rectInfo.y

const startX = e.clientX
const startY = e.clientY
this.start.x = startX - this.editorX
this.start.y = startY - this.editorY
// 展示选中区域
this.isShowArea = true

const move = (moveEvent) =&gt; {
    this.width = Math.abs(moveEvent.clientX - startX)
    this.height = Math.abs(moveEvent.clientY - startY)
    if (moveEvent.clientX &lt; startX) {
        this.start.x = moveEvent.clientX - this.editorX
    }

    if (moveEvent.clientY &lt; startY) {
        this.start.y = moveEvent.clientY - this.editorY
    }
}
</code></pre><p>在 <code>mouseup</code> 事件触发时，需要对选中区域内的所有组件的位移大小信息进行计算，得出一个能包含区域内所有组件的最小区域。这个效果如下图所示：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/1046c14a4b0e4b5c8af45533b69744a7.gif" class="kg-image" alt="1046c14a4b0e4b5c8af45533b69744a7" width="600" height="400" loading="lazy"></figure><p>这个计算过程的代码：</p><pre><code class="language-js">createGroup() {
  // 获取选中区域的组件数据
  const areaData = this.getSelectArea()
  if (areaData.length &lt;= 1) {
      this.hideArea()
      return
  }

  // 根据选中区域和区域中每个组件的位移信息来创建 Group 组件
  // 要遍历选择区域的每个组件，获取它们的 left top right bottom 信息来进行比较
  let top = Infinity, left = Infinity
  let right = -Infinity, bottom = -Infinity
  areaData.forEach(component =&gt; {
      let style = {}
      if (component.component == 'Group') {
          component.propValue.forEach(item =&gt; {
              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 &lt; left) left = style.left
              if (style.top &lt; top) top = style.top
              if (style.right &gt; right) right = style.right
              if (style.bottom &gt; bottom) bottom = style.bottom
          })
      } else {
          style = getComponentRotatedStyle(component.style)
      }

      if (style.left &lt; left) left = style.left
      if (style.top &lt; top) top = style.top
      if (style.right &gt; right) right = style.right
      if (style.bottom &gt; bottom) bottom = style.bottom
  })

  this.start.x = left
  this.start.y = top
  this.width = right - left
  this.height = bottom - top
	
  // 设置选中区域位移大小信息和区域内的组件数据
  this.$store.commit('setAreaData', {
      style: {
          left,
          top,
          width: this.width,
          height: this.height,
      },
      components: areaData,
  })
},
        
getSelectArea() {
    const result = []
    // 区域起点坐标
    const { x, y } = this.start
    // 计算所有的组件数据，判断是否在选中区域内
    this.componentData.forEach(component =&gt; {
        if (component.isLock) return
        const { left, top, width, height } = component.style
        if (x &lt;= left &amp;&amp; y &lt;= top &amp;&amp; (left + width &lt;= x + this.width) &amp;&amp; (top + height &lt;= y + this.height)) {
            result.push(component)
        }
    })
	
    // 返回在选中区域内的所有组件
    return result
}
</code></pre><p>简单描述一下这段代码的处理逻辑：</p><ol><li>利用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect">getBoundingClientRect()</a> 浏览器 API 获取每个组件相对于浏览器视口四个方向上的信息，也就是 <code>left</code> <code>top</code> <code>right</code> <code>bottom</code>。</li><li>对比每个组件的这四个信息，取得选中区域的最左、最上、最右、最下四个方向的数值，从而得出一个能包含区域内所有组件的最小区域。</li><li>如果选中区域内已经有一个 <code>Group</code> 组合组件，则需要对它里面的子组件进行计算，而不是对组合组件进行计算。</li></ol><h3 id="--1"><strong>组合后的移动、旋转</strong></h3><p>为了方便将多个组件一起进行移动、旋转、放大缩小等操作，我新创建了一个 <code>Group</code> 组合组件：</p><pre><code class="language-html">&lt;template&gt;
    &lt;div class="group"&gt;
        &lt;div&gt;
             &lt;template v-for="item in propValue"&gt;
                &lt;component
                    class="component"
                    :is="item.component"
                    :style="item.groupStyle"
                    :propValue="item.propValue"
                    :key="item.id"
                    :id="'component' + item.id"
                    :element="item"
                /&gt;
            &lt;/template&gt;
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
import { getStyle } from '@/utils/style'

export default {
    props: {
        propValue: {
            type: Array,
            default: () =&gt; [],
        },
        element: {
            type: Object,
        },
    },
    created() {
        const parentStyle = this.element.style
        this.propValue.forEach(component =&gt; {
            // component.groupStyle 的 top left 是相对于 group 组件的位置
            // 如果已存在 component.groupStyle，说明已经计算过一次了。不需要再次计算
            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 + '%'
        },
    },
}
&lt;/script&gt;

&lt;style lang="scss" scoped&gt;
.group {
    &amp; &gt; div {
        position: relative;
        width: 100%;
        height: 100%;

        .component {
            position: absolute;
        }
    }
}
&lt;/style&gt;
</code></pre><p><code>Group</code> 组件的作用就是将区域内的组件放到它下面，成为子组件。并且在创建 <code>Group</code> 组件时，获取每个子组件在 <code>Group</code> 组件内的相对位移和相对大小：</p><pre><code class="language-js">created() {
    const parentStyle = this.element.style
    this.propValue.forEach(component =&gt; {
        // component.groupStyle 的 top left 是相对于 group 组件的位置
        // 如果已存在 component.groupStyle，说明已经计算过一次了。不需要再次计算
        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 + '%'
        },
    },
</code></pre><p>也就是将子组件的 <code>left</code> <code>top</code> <code>width</code> <code>height</code> 等属性转成以 <code>%</code> 结尾的相对数值。</p><p><strong><strong>为什么不使用绝对数值</strong></strong>？</p><p>如果使用绝对数值，那么在移动 <code>Group</code> 组件时，除了对 <code>Group</code> 组件的属性进行计算外，还需要对它的每个子组件进行计算。并且 <code>Group</code> 包含子组件太多的话，在进行移动、放大缩小时，计算量会非常大，有可能会造成页面卡顿。如果改成相对数值，则只需要在 <code>Group</code> 创建时计算一次。然后在 <code>Group</code> 组件进行移动、旋转时也不用管 <code>Group</code> 的子组件，只对它自己计算即可。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/3bde69f22a7c0034f32b708447e328be.gif" class="kg-image" alt="3bde69f22a7c0034f32b708447e328be" width="600" height="400" loading="lazy"></figure><h3 id="--2"><strong>组合后的放大缩小</strong></h3><p>组合后的放大缩小是个大问题，主要是因为有旋转角度的存在。首先来看一下各个子组件没旋转时的放大缩小：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/4f80724e075ababdc9792239c356d1b8.gif" class="kg-image" alt="4f80724e075ababdc9792239c356d1b8" width="600" height="400" loading="lazy"></figure><p>从动图可以看出，效果非常完美。各个子组件的大小是跟随 <code>Group</code> 组件的大小而改变的。</p><p>现在试着给子组件加上旋转角度，再看一下效果：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/d4459e96083547f9706c3cd61cc69681.gif" class="kg-image" alt="d4459e96083547f9706c3cd61cc69681" width="600" height="400" loading="lazy"></figure><p><strong><strong>为什么会出现这个问题</strong></strong>？</p><p>主要是因为一个组件无论旋不旋转，它的 <code>top</code> <code>left</code> 属性都是不变的。这样就会有一个问题，虽然实际上组件的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件：一个没旋转，一个旋转了 45 度。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/691534c36a12f885a492800be361ba78.png" class="kg-image" alt="691534c36a12f885a492800be361ba78" width="600" height="400" loading="lazy"></figure><p>可以看出来旋转后按钮的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性和我们从外观上看到的是不一样的。</p><p>接下来再看一个具体的示例：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/b157186d6f3bef8f53cb4e982edc3a51.png" class="kg-image" alt="b157186d6f3bef8f53cb4e982edc3a51" width="600" height="400" loading="lazy"></figure><p>上面是一个 <code>Group</code> 组件，它左边的子组件属性为：</p><pre><code class="language-css">transform: rotate(-75.1967deg);
width: 51.2267%;
height: 32.2679%;
top: 33.8661%;
left: -10.6496%;
</code></pre><p>可以看到 <code>width</code> 的值为 <code>51.2267%</code>，但从外观上来看，这个子组件最多占 <code>Group</code> 组件宽度的三分之一。所以这就是放大缩小不正常的问题所在。</p><h4 id="--3"><strong>一个不可行的解决方案（不想看的可以跳过）</strong></h4><p>一开始我想的是，先算出它相对浏览器视口的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性，再算出这几个属性在 <code>Group</code> 组件上的相对数值。这可以通过 <code>getBoundingClientRect()</code> API 实现。只要维持外观上的各个属性占比不变，这样 <code>Group</code> 组件在放大缩小时，再通过旋转角度，利用旋转矩阵的知识（这一点在第二篇有详细描述）获取它未旋转前的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性。这样就可以做到子组件动态调整了。</p><p>但是这有个问题，通过 <code>getBoundingClientRect()</code> API 只能获取组件外观上的 <code>top</code> <code>left</code> <code>right</code> <code>bottom</code> <code>width</code> <code>height</code> 属性。再加上一个角度，参数还是不够，所以无法计算出组件实际的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/5a4c4f957f88e0b759ecd07c5ae8f34a.png" class="kg-image" alt="5a4c4f957f88e0b759ecd07c5ae8f34a" width="600" height="400" loading="lazy"></figure><p>就像上面的这张图，只知道原点 <code>O(x,y)</code> <code>w</code> <code>h</code> 和旋转角度，无法算出按钮的宽高。</p><h4 id="--4"><strong>一个可行的解决方案</strong></h4><p>这是无意中发现的，我在对 <code>Group</code> 组件进行放大缩小时，发现只要保持 <code>Group</code> 组件的宽高比例，子组件就能做到根据比例放大缩小。那么现在问题就转变成了<strong><strong>如何让 <code><strong>Group</strong></code> 组件放大缩小时保持宽高比例</strong></strong>。我在网上找到了这一篇<a href="https://github.com/shenhudong/snapping-demo/wiki/corner-handle">文章</a>，它详细描述了一个旋转组件如何保持宽高比来进行放大缩小，并配有源码示例。</p><p>现在我尝试简单描述一下如何保持宽高比对一个旋转组件进行放大缩小（建议还是看看原文）。下面是一个已旋转一定角度的矩形，假设现在拖动它左上方的点进行拉伸。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/8999e5e5e143fe902a59d1969151bb54.png" class="kg-image" alt="8999e5e5e143fe902a59d1969151bb54" width="600" height="400" loading="lazy"></figure><p><strong><strong>第一步</strong></strong>，算出组件宽高比，以及按下鼠标时通过组件的坐标（无论旋转多少度，组件的 <code>top</code> <code>left</code> 属性不变）和大小算出组件中心点：</p><pre><code class="language-js">// 组件宽高比
const proportion = style.width / style.height
            
const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}
</code></pre><p><strong><strong>第二步</strong></strong>，用<strong><strong>当前点击坐标</strong></strong>和组件中心点算出<strong><strong>当前点击坐标</strong></strong>的对称点坐标：</p><pre><code class="language-js">// 获取画布位移信息
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),
}
</code></pre><p><strong><strong>第三步</strong></strong>，摁住组件左上角进行拉伸时，通过当前鼠标实时坐标和对称点计算出新的组件中心点：</p><pre><code class="language-js">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),
    }
}
</code></pre><p>由于组件处于旋转状态，即使你知道了拉伸时移动的 <code>xy</code> 距离，也不能直接对组件进行计算。否则就会出现 BUG，移位或者放大缩小方向不正确。因此，我们需要在组件未旋转的情况下对其进行计算。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/da9d36c423db82bd7130c296d66a27d5.png" class="kg-image" alt="da9d36c423db82bd7130c296d66a27d5" width="600" height="400" loading="lazy"></figure><p><strong><strong>第四步</strong></strong>，根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出<strong><strong>当前鼠标实时坐标</strong></strong> <code>currentPosition</code> 在未旋转时的坐标 <code>newTopLeftPoint</code>。同时也能根据已知的旋转角度、新的组件中心点、对称点算出<strong><strong>组件对称点</strong></strong> <code>sPoint</code> 在未旋转时的坐标 <code>newBottomRightPoint</code>。</p><p>对应的计算公式如下：</p><pre><code class="language-js">/**
 * 计算根据圆心旋转后的点的坐标
 * @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,
    }
}
</code></pre><p>上面的公式涉及到线性代数中旋转矩阵的知识，对于一个没上过大学的人来说，实在太难了。还好我从知乎上的一个<a href="https://www.zhihu.com/question/67425734/answer/252724399">回答</a>中找到了这一公式的推理过程，下面是回答的原文：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/8d6388e18f987779b5e1cc3c2e0f3684.png" class="kg-image" alt="8d6388e18f987779b5e1cc3c2e0f3684" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/be69feb4415274e8dc9ddb75f402d82a.png" class="kg-image" alt="be69feb4415274e8dc9ddb75f402d82a" width="600" height="400" loading="lazy"></figure><p>通过以上几个计算值，就可以得到组件新的位移值 <code>top</code> <code>left</code> 以及新的组件大小。对应的完整代码如下：</p><pre><code class="language-js">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 &gt; 0 &amp;&amp; newHeight &gt; 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}
</code></pre><p>现在再来看一下旋转后的放大缩小：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/179256b334e7bf8851cdddbbc001f8a7.gif" class="kg-image" alt="179256b334e7bf8851cdddbbc001f8a7" width="600" height="400" loading="lazy"></figure><p><strong><strong>第五步</strong></strong>，由于我们现在需要的是锁定宽高比来进行放大缩小，所以需要重新计算拉伸后的图形的左上角坐标。</p><p>这里先确定好几个形状的命名：</p><ul><li>原图形: 　红色部分</li><li>新图形: 　蓝色部分</li><li>修正图形: 绿色部分，即加上宽高比锁定规则的修正图形</li></ul><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/c5c7e76c29a7014657f32fd1ebbdc4fd.gif" class="kg-image" alt="c5c7e76c29a7014657f32fd1ebbdc4fd" width="600" height="400" loading="lazy"></figure><p>在第四步中算出组件未旋转前的 <code>newTopLeftPoint</code> <code>newBottomRightPoint</code> <code>newWidth</code> <code>newHeight</code> 后，需要根据宽高比 <code>proportion</code> 来算出新的宽度或高度。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/275e6186904d639d47bb505e98acf308.png" class="kg-image" alt="275e6186904d639d47bb505e98acf308" width="600" height="400" loading="lazy"></figure><p>上图就是一个需要改变高度的示例，计算过程如下：</p><pre><code class="language-js">if (newWidth / newHeight &gt; proportion) {
    newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
    newWidth = newHeight * proportion
} else {
    newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
    newHeight = newWidth / proportion
}
</code></pre><p>由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的，所以缩减宽高后，需要按照原来的中心点旋转回去，获得缩减宽高并旋转后对应的坐标。然后以这个坐标和对称点获得新的中心点，并重新计算未旋转前的坐标。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/2cbce685cd8b7d95e2fad5128d9373d9.png" class="kg-image" alt="2cbce685cd8b7d95e2fad5128d9373d9" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/27b562fbf890be64c20d18af5f72f193.png" class="kg-image" alt="27b562fbf890be64c20d18af5f72f193" width="600" height="400" loading="lazy"></figure><p>经过修改后的完整代码如下：</p><pre><code class="language-js">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 &gt; proportion) {
            newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
            newWidth = newHeight * proportion
        } else {
            newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
            newHeight = newWidth / proportion
        }

        // 由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的
        // 所以缩减宽高后，需要按照原来的中心点旋转回去，获得缩减宽高并旋转后对应的坐标
        // 然后以这个坐标和对称点获得新的中心点，并重新计算未旋转前的坐标
        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 &gt; 0 &amp;&amp; newHeight &gt; 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}
</code></pre><p>保持宽高比进行放大缩小的效果如下：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/3e09ef0c99ae072a12d51b05343be6ca.gif" class="kg-image" alt="3e09ef0c99ae072a12d51b05343be6ca" width="600" height="400" loading="lazy"></figure><p>当 <code>Group</code> 组件有旋转的子组件时，才需要保持宽高比进行放大缩小。所以在创建 <code>Group</code> 组件时可以判断一下子组件是否有旋转角度。如果没有，就不需要保持宽度比进行放大缩小。</p><pre><code class="language-js">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
}
</code></pre><h3 id="--5"><strong>拆分后子组件样式的恢复</strong></h3><p>将多个组件组合在一起只是第一步，第二步是将 <code>Group</code> 组件进行拆分并恢复各个子组件的样式。保证拆分后的子组件在外观上的属性不变。</p><p>计算代码如下：</p><pre><code class="language-js">// store
decompose({ curComponent, editor }) {
    const parentStyle = { ...curComponent.style }
    const components = curComponent.propValue
    const editorRect = editor.getBoundingClientRect()

    store.commit('deleteComponent')
    components.forEach(component =&gt; {
        decomposeComponent(component, editorRect, parentStyle)
        store.commit('addComponent', { component })
    })
}
        
// 将组合中的各个子组件拆分出来，并计算它们新的 style
export default function decomposeComponent(component, editorRect, parentStyle) {
    // 子组件相对于浏览器视口的样式
    const componentRect = $(`#component${component.id}`).getBoundingClientRect()
    // 获取元素的中心点坐标
    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
    // 计算出元素新的 top left 坐标
    component.style.left = center.x - component.style.width / 2
    component.style.top = center.y - component.style.height / 2
    component.groupStyle = {}
}
</code></pre><p>这段代码的处理逻辑为：</p><ol><li>遍历 <code>Group</code> 的子组件并恢复它们的样式</li><li>利用 <code>getBoundingClientRect()</code> API 获取子组件相对于浏览器视口的 <code>left</code> <code>top</code> <code>width</code> <code>height</code> 属性。</li><li>利用这四个属性计算出子组件的中心点坐标。</li><li>由于子组件的 <code>width</code> <code>height</code> 属性是相对于 <code>Group</code> 组件的，所以将它们的百分比值和 <code>Group</code> 相乘得出具体数值。</li><li>再用中心点 <code>center(x, y)</code> 减去子组件宽高的一半得出它的 <code>left</code> <code>top</code> 属性。</li></ol><p>至此，组合和拆分就讲解完了。</p><h2 id="19-"><strong>19. 文本组件</strong></h2><p>文本组件 <code>VText</code> 之前就已经实现过了，但不完美。例如无法对文字进行选中。现在我对它进行了重写，让它支持选中功能。</p><pre><code class="language-html">&lt;template&gt;
    &lt;div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup"&gt;
        &lt;!-- tabindex &gt;= 0 使得双击时聚集该元素 --&gt;
        &lt;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 }"
        &gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div v-else class="v-text"&gt;
        &lt;div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"&gt;&lt;/div&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
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 &amp;&amp; this.canEdit &amp;&amp; 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 || '&amp;nbsp;'
            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)
        },
    },
}
&lt;/script&gt;

&lt;style lang="scss" scoped&gt;
.v-text {
    width: 100%;
    height: 100%;
    display: table;

    div {
        display: table-cell;
        width: 100%;
        height: 100%;
        outline: none;
    }

    .canEdit {
        cursor: text;
        height: 100%;
    }
}
&lt;/style&gt;
</code></pre><p>改造后的 <code>VText</code> 组件功能如下：</p><ol><li>双击启动编辑。</li><li>支持选中文本。</li><li>粘贴时过滤掉文本的样式。</li><li>换行时自动扩充文本框的高度。</li></ol><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/52729c9b0bf49d5348bca04477906a37.gif" class="kg-image" alt="52729c9b0bf49d5348bca04477906a37" width="600" height="400" loading="lazy"></figure><h2 id="20-"><strong>20. 矩形组件</strong></h2><p>矩形组件其实就是一个内嵌 <code>VText</code> 文本组件的一个 DIV。</p><pre><code class="language-html">&lt;template&gt;
    &lt;div class="rect-shape"&gt;
        &lt;v-text :propValue="element.propValue" :element="element" /&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
    props: {
        element: {
            type: Object,
        },
    },
}
&lt;/script&gt;

&lt;style lang="scss" scoped&gt;
.rect-shape {
    width: 100%;
    height: 100%;
    overflow: auto;
}
&lt;/style&gt;
</code></pre><p><code>VText</code> 文本组件有的功能它都有，并且可以任意放大缩小。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/58e03a43f436194e7cb4fa6fea81a24a.gif" class="kg-image" alt="58e03a43f436194e7cb4fa6fea81a24a" width="600" height="400" loading="lazy"></figure><h2 id="21-"><strong>21. 锁定组件</strong></h2><p>锁定组件主要是看到 <code>processon</code> 和墨刀有这个功能，于是我顺便实现了。锁定组件的具体需求为：不能移动、放大缩小、旋转、复制、粘贴等，只能进行解锁操作。</p><p>它的实现原理也不难：</p><ol><li>在自定义组件上加一个 <code>isLock</code> 属性，表示是否锁定组件。</li><li>在点击组件时，根据 <code>isLock</code> 是否为 <code>true</code> 来隐藏组件上的八个点和旋转图标。</li><li>为了突出一个组件被锁定，给它加上透明度属性和一个锁的图标。</li><li>如果组件被锁定，置灰上面所说的需求对应的按钮，不能被点击。</li></ol><p>相关代码如下：</p><pre><code class="language-js">export const commonAttr = {
    animations: [],
    events: {},
    groupStyle: {}, // 当一个组件成为 Group 的子组件时使用
    isLock: false, // 是否锁定组件
}
</code></pre><pre><code class="language-html">&lt;el-button @click="decompose" 
:disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'"&gt;拆分&lt;/el-button&gt;

&lt;el-button @click="lock" :disabled="!curComponent || curComponent.isLock"&gt;锁定&lt;/el-button&gt;
&lt;el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock"&gt;解锁&lt;/el-button&gt;
</code></pre><pre><code class="language-html">&lt;template&gt;
    &lt;div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }"&gt;
        &lt;ul @mouseup="handleMouseUp"&gt;
            &lt;template v-if="curComponent"&gt;
                &lt;template v-if="!curComponent.isLock"&gt;
                    &lt;li @click="copy"&gt;复制&lt;/li&gt;
                    &lt;li @click="paste"&gt;粘贴&lt;/li&gt;
                    &lt;li @click="cut"&gt;剪切&lt;/li&gt;
                    &lt;li @click="deleteComponent"&gt;删除&lt;/li&gt;
                    &lt;li @click="lock"&gt;锁定&lt;/li&gt;
                    &lt;li @click="topComponent"&gt;置顶&lt;/li&gt;
                    &lt;li @click="bottomComponent"&gt;置底&lt;/li&gt;
                    &lt;li @click="upComponent"&gt;上移&lt;/li&gt;
                    &lt;li @click="downComponent"&gt;下移&lt;/li&gt;
                &lt;/template&gt;
                &lt;li v-else @click="unlock"&gt;解锁&lt;/li&gt;
            &lt;/template&gt;
            &lt;li v-else @click="paste"&gt;粘贴&lt;/li&gt;
        &lt;/ul&gt;
    &lt;/div&gt;
&lt;/template&gt;
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/b8f76179644f575fb02c3900c60dd2cb.gif" class="kg-image" alt="b8f76179644f575fb02c3900c60dd2cb" width="600" height="400" loading="lazy"></figure><h2 id="22-"><strong>22. 快捷键</strong></h2><p>支持快捷键主要是为了提升开发效率，用鼠标点点点毕竟没有按键盘快。目前快捷键支持的功能如下：</p><pre><code class="language-js">const ctrlKey = 17, 
    vKey = 86, // 粘贴
    cKey = 67, // 复制
    xKey = 88, // 剪切

    yKey = 89, // 重做
    zKey = 90, // 撤销

    gKey = 71, // 组合
    bKey = 66, // 拆分

    lKey = 76, // 锁定
    uKey = 85, // 解锁

    sKey = 83, // 保存
    pKey = 80, // 预览
    dKey = 68, // 删除
    deleteKey = 46, // 删除
    eKey = 69 // 清空画布
</code></pre><p>实现原理主要是利用 window 全局监听按键事件，在符合条件的按键触发时执行对应的操作：</p><pre><code class="language-js">// 与组件状态无关的操作
const basemap = {
    [vKey]: paste,
    [yKey]: redo,
    [zKey]: undo,
    [sKey]: save,
    [pKey]: preview,
    [eKey]: clearCanvas,
}

// 组件锁定状态下可以执行的操作
const lockMap = {
    ...basemap,
    [uKey]: unlock,
}

// 组件未锁定状态下可以执行的操作
const unlockMap = {
    ...basemap,
    [cKey]: copy,
    [xKey]: cut,
    [gKey]: compose,
    [bKey]: decompose,
    [dKey]: deleteComponent,
    [deleteKey]: deleteComponent,
    [lKey]: lock,
}

let isCtrlDown = false
// 全局监听按键操作并执行相应命令
export function listenGlobalKeyDown() {
    window.onkeydown = (e) =&gt; {
        const { curComponent } = store.state
        if (e.keyCode == ctrlKey) {
            isCtrlDown = true
        } else if (e.keyCode == deleteKey &amp;&amp; curComponent) {
            store.commit('deleteComponent')
            store.commit('recordSnapshot')
        } else if (isCtrlDown) {
            if (!curComponent || !curComponent.isLock) {
                e.preventDefault()
                unlockMap[e.keyCode] &amp;&amp; unlockMap[e.keyCode]()
            } else if (curComponent &amp;&amp; curComponent.isLock) {
                e.preventDefault()
                lockMap[e.keyCode] &amp;&amp; lockMap[e.keyCode]()
            }
        }
    }

    window.onkeyup = (e) =&gt; {
        if (e.keyCode == ctrlKey) {
            isCtrlDown = false
        }
    }
}
</code></pre><p>为了防止和浏览器默认快捷键冲突，所以需要加上 <code>e.preventDefault()</code>。</p><h2 id="23-"><strong>23. 网格线</strong></h2><p>网格线功能使用 SVG 来实现：</p><pre><code class="language-html">&lt;template&gt;
    &lt;svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"&gt;
        &lt;defs&gt;
            &lt;pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse"&gt;
                &lt;path 
                    d="M 7.236328125 0 L 0 0 0 7.236328125" 
                    fill="none" 
                    stroke="rgba(207, 207, 207, 0.3)" 
                    stroke-width="1"&gt;
                &lt;/path&gt;
            &lt;/pattern&gt;
            &lt;pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse"&gt;
                &lt;rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"&gt;&lt;/rect&gt;
                &lt;path 
                    d="M 36.181640625 0 L 0 0 0 36.181640625" 
                    fill="none" 
                    stroke="rgba(186, 186, 186, 0.5)" 
                    stroke-width="1"&gt;
                &lt;/path&gt;
            &lt;/pattern&gt;
        &lt;/defs&gt;
        &lt;rect width="100%" height="100%" fill="url(#grid)"&gt;&lt;/rect&gt;
    &lt;/svg&gt;
&lt;/template&gt;

&lt;style lang="scss" scoped&gt;
.grid {
    position: absolute;
    top: 0;
    left: 0;
}
&lt;/style&gt;
</code></pre><p>对 SVG 不太懂的，建议看一下 MDN 的<a href="https://developer.mozilla.org/zh-CN/docs/Web/SVG">教程</a>。</p><h2 id="24-"><strong>24. 编辑器快照的另一种实现方式</strong></h2><p>在系列文章的第一篇中，我已经分析过快照的实现原理。</p><pre><code class="language-js">snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
        
undo(state) {
    if (state.snapshotIndex &gt;= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

redo(state) {
    if (state.snapshotIndex &lt; 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 &lt; state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
},
</code></pre><p>用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 <code>push()</code> 操作，将当前的编辑器数据推入 <code>snapshotData</code> 数组，并增加快照索引 <code>snapshotIndex</code>。</p><p>由于每一次添加快照都是将当前编辑器的所有组件数据推入 <code>snapshotData</code>，保存的快照数据越多占用的内存就越多。对此有两个解决方案：</p><ol><li>限制快照步数，例如只能保存 50 步的快照数据。</li><li>保存快照只保存差异部分。</li></ol><p><strong><strong>现在详细描述一下第二个解决方案</strong></strong>。</p><p>假设依次往画布上添加 a b c d 四个组件，在原来的实现中，对应的 <code>snapshotData</code> 数据为：</p><pre><code class="language-js">// snapshotData
[
  [a],
  [a, b],
  [a, b, c],
  [a, b, c, d],
]
</code></pre><p>从上面的代码可以发现，每一相邻的快照中，只有一个数据是不同的。所以我们可以为每一步的快照添加一个类型字段，用来表示此次操作是添加还是删除。</p><p>那么上面添加四个组件的操作，所对应的 <code>snapshotData</code> 数据为：</p><pre><code>// snapshotData
[
  [{ type: 'add', value: a }],
  [{ type: 'add', value: b }],
  [{ type: 'add', value: c }],
  [{ type: 'add', value: d }],
]
</code></pre><p>如果我们要删除 c 组件，那么 <code>snapshotData</code> 数据将变为：</p><pre><code>// snapshotData
[
  [{ type: 'add', value: a }],
  [{ type: 'add', value: b }],
  [{ type: 'add', value: c }],
  [{ type: 'add', value: d }],
  [{ type: 'remove', value: c }],
]
</code></pre><p><strong><strong>那如何使用现在的快照数据呢</strong></strong>？</p><p>我们需要遍历一遍快照数据，来生成编辑器的组件数据 <code>componentData</code>。假设在上面的数据基础上执行了 <code>undo</code> 撤销操作：</p><pre><code>// snapshotData
// 快照索引 snapshotIndex 此时为 3
[
  [{ type: 'add', value: a }],
  [{ type: 'add', value: b }],
  [{ type: 'add', value: c }],
  [{ type: 'add', value: d }],
  [{ type: 'remove', value: c }],
]
</code></pre><ol><li><code>snapshotData[0]</code> 类型为 <code>add</code>，将组件 a 添加到 <code>componentData</code> 中，此时 <code>componentData</code> 为 <code>[a]</code></li><li>依次类推 <code>[a, b]</code></li><li><code>[a, b, c]</code></li><li><code>[a, b, c, d]</code></li></ol><p>如果这时执行 <code>redo</code> 重做操作，快照索引 <code>snapshotIndex</code> 变为 4。对应的快照数据类型为 <code>type: 'remove'</code>， 移除组件 c。则数组数据为 <code>[a, b, d]</code>。</p><p>这种方法其实就是时间换空间，虽然每一次保存的快照数据只有一项，但每次都得遍历一遍所有的快照数据。两种方法都不完美，要使用哪种取决于你，目前我仍在使用第一种方法。</p><h2 id="--6"><strong>总结</strong></h2><p>从造轮子的角度来看，这是我目前造的第四个比较满意的轮子，其他三个为：</p><ul><li><a href="https://github.com/woai3c/nand2tetris">nand2tetris</a></li><li><a href="https://github.com/woai3c/MIT6.828">MIT6.828</a></li><li><a href="https://github.com/woai3c/mini-vue">mini-vue</a></li></ul><p>造轮子是一个很好的提升自己技术水平的方法，但造轮子一定要造有意义、有难度的轮子，并且同类型的轮子只造一个。造完轮子后，还需要写总结，最好输出成文章分享出去。</p><h2 id="--7"><strong>参考资料</strong></h2><ul><li><a href="https://github.com/shenhudong/snapping-demo/wiki/corner-handle">snapping-demo</a></li><li><a href="https://www.processon.com/">processon</a></li><li><a href="https://modao.cc/">墨刀</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 可视化拖拽组件库一些技术要点原理分析（二） ]]>
                </title>
                <description>
                    <![CDATA[ 本文是对《可视化拖拽组件库一些技术要点原理分析》 [https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/] 的补充。上一篇文章主要讲解了以下几个功能点：  1.  编辑器  2.  自定义组件  3.  拖拽  4.  删除组件、调整图层层级  5.  放大缩小  6.  撤消、重做  7.  组件属性设置  8.  吸附  9.  预览、保存代码  10. 绑定事件  11. 绑定动画  12. 导入 PSD  ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-2/</link>
                <guid isPermaLink="false">6004ff425f61e30501b5bdda</guid>
                
                    <category>
                        <![CDATA[ 可视化 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vue ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web开发 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Mon, 18 Jan 2021 07:50:07 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/01/michael-dziedzic-gEN5Btvf2Eg-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文是对<a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/">《可视化拖拽组件库一些技术要点原理分析》</a>的补充。上一篇文章主要讲解了以下几个功能点：</p><ol><li>编辑器</li><li>自定义组件</li><li>拖拽</li><li>删除组件、调整图层层级</li><li>放大缩小</li><li>撤消、重做</li><li>组件属性设置</li><li>吸附</li><li>预览、保存代码</li><li>绑定事件</li><li>绑定动画</li><li>导入 PSD</li><li>手机模式</li></ol><p>现在这篇文章会在此基础上再补充 4 个功能点，分别是：</p><ul><li>拖拽旋转</li><li>复制粘贴剪切</li><li>数据交互</li><li>发布</li></ul><p>和上篇文章一样，我已经将新功能的代码更新到了 github：</p><ul><li><a href="https://github.com/woai3c/visual-drag-demo">github 项目地址</a></li><li><a href="https://woai3c.github.io/visual-drag-demo">在线预览</a></li></ul><p><strong><strong>友善提醒</strong></strong>：建议结合源码一起阅读，效果更好（这个 DEMO 使用的是 Vue 技术栈）。</p><h2 id="14-"><strong>14. 拖拽旋转</strong></h2><p>在写上一篇文章时，原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方：</p><ol><li>不支持拖拽旋转。</li><li>旋转后的放大缩小不正确。</li><li>旋转后的自动吸附不正确。</li><li>旋转后八个可伸缩点的光标不正确。</li></ol><p>这一小节，我们将逐一解决这四个问题。</p><h3 id="-"><strong>拖拽旋转</strong></h3><p>拖拽旋转需要使用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2">Math.atan2()</a> 函数。</p><p>Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值)，也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。</p><p>简单的说就是以组件中心点为原点 <code>(centerX,centerY)</code>，用户按下鼠标时的坐标设为 <code>(startX,startY)</code>，鼠标移动时的坐标设为 <code>(curX,curY)</code>。旋转角度可以通过 <code>(startX,startY)</code> 和 <code>(curX,curY)</code> 计算得出。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/c2de0e4cd42f3c2a2a74b2fa5d38f8e6.png" class="kg-image" alt="c2de0e4cd42f3c2a2a74b2fa5d38f8e6" width="339" height="282" loading="lazy"></figure><p>那我们如何得到从点 <code>(startX,startY)</code> 到点 <code>(curX,curY)</code> 之间的旋转角度呢？</p><p><strong><strong>第一步</strong></strong>，鼠标点击时的坐标设为 <code>(startX,startY)</code>：</p><pre><code class="language-js">const startY = e.clientY
const startX = e.clientX
</code></pre><p><strong><strong>第二步</strong></strong>，算出组件中心点：</p><pre><code class="language-js">// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
</code></pre><p><strong><strong>第三步</strong></strong>，按住鼠标移动时的坐标设为 <code>(curX,curY)</code>：</p><pre><code class="language-js">const curX = moveEvent.clientX
const curY = moveEvent.clientY
</code></pre><p><strong><strong>第四步</strong></strong>，分别算出 <code>(startX,startY)</code> 和 <code>(curX,curY)</code> 对应的角度，再将它们相减得出旋转的角度。另外，还需要注意的就是 <code>Math.atan2()</code> 方法的返回值是一个弧度，因此还需要将弧度转化为角度。所以完整的代码为：</p><pre><code class="language-js">// 旋转前的角度
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
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/f72edf99eeacbce08c5a954ec908350c.gif" class="kg-image" alt="f72edf99eeacbce08c5a954ec908350c" width="228" height="197" loading="lazy"></figure><h3 id="--1"><strong>放大缩小</strong></h3><p>组件旋转后的放大缩小会有 BUG。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/d7788970d2e1567c15ad0fc7985445cc.gif" class="kg-image" alt="d7788970d2e1567c15ad0fc7985445cc" width="1007" height="597" loading="lazy"></figure><p>从上图可以看到，放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是：当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度，放大缩小仍然是按没旋转时计算的。</p><p>下面再看一个具体的示例：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/3e815a59f74b2a51bb1141f716c17e12.png" class="kg-image" alt="3e815a59f74b2a51bb1141f716c17e12" width="285" height="177" loading="lazy"></figure><p>从上图可以看出，在没有旋转时，按住顶点往上拖动，只需用 <code>y2 - y1</code> 就可以得出拖动距离 <code>s</code>。这时将组件原来的高度加上 <code>s</code> 就能得出新的高度，同时将组件的 <code>top</code>、<code>left</code> 属性更新。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/adeedf9ff0fbc9da68259b8021a3ea69.png" class="kg-image" alt="adeedf9ff0fbc9da68259b8021a3ea69" width="172" height="186" loading="lazy"></figure><p>现在旋转 180 度，如果这时拖住顶点往下拖动，我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的，所以结果和我们期待的相反，组件的高度将会变小（如果不理解这个现象，可以想像一下没有旋转的那张图，按住顶点往下拖动）。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/f013c01874f77de619a910ed614d864f.gif" class="kg-image" alt="f013c01874f77de619a910ed614d864f" width="600" height="401" loading="lazy"></figure><p>如何解决这个问题呢？我从 github 上的一个项目 <a href="https://github.com/shenhudong/snapping-demo/wiki/corner-handle">snapping-demo</a> 找到了解决方案：将放大缩小和旋转角度关联起来。</p><h4 id="--2"><strong>解决方案</strong></h4><p>下面是一个已旋转一定角度的矩形，假设现在拖动它左上方的点进行拉伸。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/8999e5e5e143fe902a59d1969151bb54.png" class="kg-image" alt="8999e5e5e143fe902a59d1969151bb54" width="1043" height="1142" loading="lazy"></figure><p>现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。</p><p><strong><strong>第一步</strong></strong>，按下鼠标时通过组件的坐标（无论旋转多少度，组件的 <code>top</code> <code>left</code> 属性不变）和大小算出组件中心点：</p><pre><code class="language-js">const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}
</code></pre><p><strong><strong>第二步</strong></strong>，用<strong><strong>当前点击坐标</strong></strong>和组件中心点算出<strong><strong>当前点击坐标</strong></strong>的对称点坐标：</p><pre><code class="language-js">// 获取画布位移信息
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),
}
</code></pre><p><strong><strong>第三步</strong></strong>，摁住组件左上角进行拉伸时，通过当前鼠标实时坐标和对称点计算出新的组件中心点：</p><pre><code class="language-js">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),
    }
}
</code></pre><p>由于组件处于旋转状态，即使你知道了拉伸时移动的 <code>xy</code> 距离，也不能直接对组件进行计算。否则就会出现 BUG，移位或者放大缩小方向不正确。因此，我们需要在组件未旋转的情况下对其进行计算。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/da9d36c423db82bd7130c296d66a27d5.png" class="kg-image" alt="da9d36c423db82bd7130c296d66a27d5" width="1042" height="1142" loading="lazy"></figure><p><strong><strong>第四步</strong></strong>，根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出<strong><strong>当前鼠标实时坐标</strong></strong> <code>currentPosition</code> 在未旋转时的坐标 <code>newTopLeftPoint</code>。同时也能根据已知的旋转角度、新的组件中心点、对称点算出<strong><strong>组件对称点</strong></strong> <code>sPoint</code> 在未旋转时的坐标 <code>newBottomRightPoint</code>。</p><p>对应的计算公式如下：</p><pre><code class="language-js">/**
 * 计算根据圆心旋转后的点的坐标
 * @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,
    }
}
</code></pre><p>上面的公式涉及到线性代数中旋转矩阵的知识，对于一个没上过大学的人来说，实在太难了。还好我从知乎上的一个<a href="https://www.zhihu.com/question/67425734/answer/252724399">回答</a>中找到了这一公式的推理过程，下面是回答的原文：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/8d6388e18f987779b5e1cc3c2e0f3684.png" class="kg-image" alt="8d6388e18f987779b5e1cc3c2e0f3684" width="658" height="442" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/be69feb4415274e8dc9ddb75f402d82a.png" class="kg-image" alt="be69feb4415274e8dc9ddb75f402d82a" width="578" height="315" loading="lazy"></figure><p>通过以上几个计算值，就可以得到组件新的位移值 <code>top</code> <code>left</code> 以及新的组件大小。对应的完整代码如下：</p><pre><code class="language-js">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 &gt; 0 &amp;&amp; newHeight &gt; 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}
</code></pre><p>现在再来看一下旋转后的放大缩小：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/179256b334e7bf8851cdddbbc001f8a7.gif" class="kg-image" alt="179256b334e7bf8851cdddbbc001f8a7" width="307" height="190" loading="lazy"></figure><h3 id="--3"><strong>自动吸附</strong></h3><p>自动吸附是根据组件的四个属性 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 计算的，在将组件进行旋转后，这些属性的值是不会变的。所以无论组件旋转多少度，吸附时仍然按未旋转时计算。这样就会有一个问题，虽然实际上组件的 <code>top</code> <code>left</code> <code>width</code> <code>height</code> 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件：一个没旋转，一个旋转了 45 度。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/691534c36a12f885a492800be361ba78.png" class="kg-image" alt="691534c36a12f885a492800be361ba78" width="279" height="106" loading="lazy"></figure><p>可以看出来旋转后按钮的 <code>height</code> 属性和我们从外观上看到的高度是不一样的，所以在这种情况下就出现了吸附不正确的 BUG。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/f31472ccab5dad26a873d29520360335.gif" class="kg-image" alt="f31472ccab5dad26a873d29520360335" width="1007" height="597" loading="lazy"></figure><h4 id="--4"><strong>解决方案</strong></h4><p>如何解决这个问题？我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比，而是拿我们看到的大小和位移做对比。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/c998d115f6b3ba179d996ec1785feebb.png" class="kg-image" alt="c998d115f6b3ba179d996ec1785feebb" width="280" height="269" loading="lazy"></figure><p>从上图可以看出，旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出，左边的红线用正弦计算，右边的红线用余弦计算：</p><pre><code class="language-js">const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
</code></pre><p>同理，高度也是一样：</p><pre><code class="language-js">const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
</code></pre><p>新的宽度和高度有了，再根据组件原有的 <code>top</code> <code>left</code> 属性，可以得出组件旋转后新的 <code>top</code> <code>left</code> 属性。下面附上完整代码：</p><pre><code class="language-js">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
}
</code></pre><p>经过修复后，吸附也可以正常显示了。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/bb59ba649d6f71c49618177fccff5129.gif" class="kg-image" alt="bb59ba649d6f71c49618177fccff5129" width="368" height="270" loading="lazy"></figure><h3 id="--5"><strong>光标</strong></h3><p>光标和可拖动的方向不对，是因为八个点的光标是固定设置的，没有随着角度变化而变化。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/c66d1cdbc8b3dae2697c1f3c21321df7.gif" class="kg-image" alt="c66d1cdbc8b3dae2697c1f3c21321df7" width="691" height="480" loading="lazy"></figure><h4 id="--6"><strong>解决方案</strong></h4><p>由于 <code>360 / 8 = 45</code>，所以可以为每一个方向分配 45 度的范围，每个范围对应一个光标。同时为每个方向设置一个初始角度，也就是未旋转时组件每个方向对应的角度。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/bdd8c41ba9c9411a65c539ff555d163b.png" class="kg-image" alt="bdd8c41ba9c9411a65c539ff555d163b" width="409" height="316" loading="lazy"></figure><pre><code class="language-js">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: {},
</code></pre><p>计算方式也很简单：</p><ol><li>假设现在组件已旋转了一定的角度 a。</li><li>遍历八个方向，用每个方向的初始角度 + a 得出现在的角度 b。</li><li>遍历 <code>angleToCursor</code> 数组，看看 b 在哪一个范围中，然后将对应的光标返回。</li></ol><p>经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下：</p><pre><code class="language-js">getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数，所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个，降低时间复杂度
    pointList.forEach(point =&gt; {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle &lt; 23 || angle &gt;= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start &lt;= angle &amp;&amp; angle &lt; angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/6e5bb1c62178c24bbbae485831ab7304.gif" class="kg-image" alt="6e5bb1c62178c24bbbae485831ab7304" width="368" height="270" loading="lazy"></figure><p>从上面的动图可以看出来，现在八个方向上的光标是可以正确显示的。</p><h2 id="15-"><strong>15. 复制粘贴剪切</strong></h2><p>相对于拖拽旋转功能，复制粘贴就比较简单了。</p><pre><code class="language-js">const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) =&gt; {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown &amp;&amp; e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown &amp;&amp; e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown &amp;&amp; e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) =&gt; {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}
</code></pre><p>监听用户的按键操作，在按下特定按键时触发对应的操作。</p><h3 id="--7"><strong>复制操作</strong></h3><p>在 vuex 中使用 <code>copyData</code> 来表示复制的数据。当用户按下 <code>ctrl + c</code> 时，将当前组件数据深拷贝到 <code>copyData</code>。</p><pre><code class="language-js">copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},
</code></pre><p>同时需要将当前组件在组件数据中的索引记录起来，在剪切中要用到。</p><h3 id="--8"><strong>粘贴操作</strong></h3><pre><code class="language-js">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
},
</code></pre><p>粘贴时，如果是按键操作 <code>ctrl+v</code>。则将组件的 <code>top</code> <code>left</code> 属性加 10，以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作，则将复制的组件放到鼠标点击处。</p><h3 id="--9"><strong>剪切操作</strong></h3><pre><code class="language-js">cut({ copyData }) {
    if (copyData) {
        store.commit('addComponent', { component: copyData.data, index: copyData.index })
    }

    store.commit('copy')
    store.commit('deleteComponent')
},
</code></pre><p>剪切操作本质上还是复制，只不过在执行复制后，需要将当前组件删除。为了避免用户执行剪切操作后，不执行粘贴操作，而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了，可以通过索引将原来的数据恢复到原来的位置中。</p><h3 id="--10"><strong>右键操作</strong></h3><p>右键操作和按键操作是一样的，一个功能两种触发途径。</p><pre><code class="language-html">&lt;li @click="copy" v-show="curComponent"&gt;复制&lt;/li&gt;
&lt;li @click="paste"&gt;粘贴&lt;/li&gt;
&lt;li @click="cut" v-show="curComponent"&gt;剪切&lt;/li&gt;

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},
</code></pre><h2 id="16-"><strong>16. 数据交互</strong></h2><h3 id="--11"><strong>方式一</strong></h3><p>提前写好一系列 ajax 请求API，点击组件时按需选择 API，选好 API 再填参数。例如下面这个组件，就展示了如何使用 ajax 请求向后台交互：</p><pre><code class="language-html">&lt;template&gt;
    &lt;div&gt;{{ propValue.data }}&lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () =&gt; {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res =&gt; {
            this.propValue.data = res.data
        })
    },
}
&lt;/script&gt;
</code></pre><h3 id="--12"><strong>方式二</strong></h3><p>方式二适合纯展示的组件，例如有一个报警组件，可以根据后台传来的数据显示对应的颜色。在编辑页面的时候，可以通过 ajax 向后台请求页面能够使用的 websocket 数据：</p><pre><code class="language-js">const data = ['status', 'text'...]
</code></pre><p>然后再为不同的组件添加上不同的属性。例如有 a 组件，它绑定的属性为 <code>status</code>。</p><pre><code>// 组件能接收的数据
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},
</code></pre><p>在组件中通过 <code>wsKey</code> 获取这个绑定的属性。等页面发布后或者预览时，通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 <code>wsKey</code> 访问数据了。</p><pre><code class="language-html">&lt;template&gt;
    &lt;div&gt;{{ wsData[wsKey] }}&lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
&lt;/script&gt;
</code></pre><p>和后台交互的方式有很多种，不仅仅包括上面两种，我在这里仅提供一些思路，以供参考。</p><h2 id="17-"><strong>17. 发布</strong></h2><p>页面发布有两种方式：一是将组件数据渲染为一个单独的 HTML 页面；二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。</p><p>这里说一下第二种方式，本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端，同时为每个页面生成一个唯一 ID。</p><p>假设现在有三个页面，发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上，这样就可以根据 ID 获取每个页面对应的组件数据。</p><pre><code class="language-js">www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b
</code></pre><h3 id="--13"><strong>按需加载</strong></h3><p>如果自定义组件过大，例如有数十个甚至上百个。这时可以将自定义组件用 <code>import</code> 的方式导入，做到按需加载，减少首屏渲染时间：</p><pre><code class="language-js">import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key =&gt; {
    Vue.component(key, () =&gt; import(`@/custom-component/${key}`))
})
</code></pre><h3 id="--14"><strong>按版本发布</strong></h3><p>自定义组件有可能会有更新的情况。例如原来的组件使用了大半年，现在有功能变更，为了不影响原来的页面。建议在发布时带上组件的版本号：</p><pre><code>- v-text
  - v1.vue
  - v2.vue
</code></pre><p>例如 <code>v-text</code> 组件有两个版本，在左侧组件列表区使用时就可以带上版本号：</p><pre><code class="language-js">{
  component: 'v-text',
  version: 'v1'
  ...
}
</code></pre><p>这样导入组件时就可以根据组件版本号进行导入：</p><pre><code class="language-js">import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component =&gt; {
    Vue.component(component.name, () =&gt; import(`@/custom-component/${component.name}/${component.version}`))
})
</code></pre><h2 id="--15"><strong>参考资料</strong></h2><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math">Math</a></li><li><a href="https://www.jianshu.com/p/9817e267925a">通过Math.atan2 计算角度</a></li><li><a href="https://www.zhihu.com/question/67425734/answer/252724399">为什么矩阵能用来表示角的旋转？</a></li><li><a href="https://github.com/shenhudong/snapping-demo/wiki/corner-handle">snapping-demo</a></li><li><a href="https://github.com/lycHub/vue-next-drag">vue-next-drag</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 关于可视化拖拽组件库技术要点原理的分析 ]]>
                </title>
                <description>
                    <![CDATA[ 本文通过一个可视化拖拽组件库 DEMO，对以下技术要点进行分析：  1.  编辑器  2.  自定义组件  3.  拖拽  4.  删除组件、调整图层层级  5.  放大缩小  6.  撤消、重做  7.  组件属性设置  8.  吸附  9.  预览、保存代码  10. 绑定事件  11. 绑定动画  12. 导入 PSD  13. ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library/</link>
                <guid isPermaLink="false">5fe09ce139641a0517d524e8</guid>
                
                    <category>
                        <![CDATA[ 可视化 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vue ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web开发 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 22 Dec 2020 02:58:48 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2020/12/james-harrison-vpOeXr5wmR4-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文通过一个可视化拖拽组件库 DEMO，对以下技术要点进行分析：</p><ol><li>编辑器</li><li>自定义组件</li><li>拖拽</li><li>删除组件、调整图层层级</li><li>放大缩小</li><li>撤消、重做</li><li>组件属性设置</li><li>吸附</li><li>预览、保存代码</li><li>绑定事件</li><li>绑定动画</li><li>导入 PSD</li><li>手机模式</li></ol><p>这个 DEMO 使用的是 Vue 技术栈，建议读者结合源码一起阅读，效果更好。</p><ul><li><a href="https://github.com/woai3c/visual-drag-demo">GitHub 项目地址</a></li><li><a href="https://woai3c.github.io/visual-drag-demo">在线预览</a></li></ul><h2 id="1-"><strong>1. 编辑器</strong></h2><p>先来看一下页面的整体结构。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/b6df0431fb7b27738df5edd7911d60aa.png" class="kg-image" alt="b6df0431fb7b27738df5edd7911d60aa" width="600" height="400" loading="lazy"></figure><p>这一节要讲的编辑器其实就是中间的画布。它的作用是：当从左边组件列表拖拽出一个组件放到画布中时，画布要把这个组件渲染出来。</p><p>这个编辑器的实现思路是：</p><ol><li>用一个数组 <code>componentData</code> 维护编辑器中的数据。</li><li>把组件拖拽到画布中时，使用 <code>push()</code> 方法将新的组件数据添加到 <code>componentData</code>。</li><li>编辑器使用 <code>v-for</code> 指令遍历 <code>componentData</code>，将每个组件逐个渲染到画布（也可以使用 JSX 语法结合 <code>render()</code> 方法代替）。</li></ol><p>编辑器渲染的核心代码如下所示：</p><pre><code class="language-js">&lt;component 
  v-for="item in componentData"
  :key="item.id"
  :is="item.component"
  :style="item.style"
  :propValue="item.propValue"
/&gt;
</code></pre><p>每个组件数据大概是这样：</p><pre><code class="language-js">{
    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: '',
    },
}
</code></pre><p>在遍历 <code>componentData</code> 组件数据时，主要靠 <code>is</code> 属性来识别出真正要渲染的是哪个组件。</p><p>例如要渲染的组件数据是 <code>{ component: 'v-text' }</code>，则 <code>&lt;component :is="item.component" /&gt;</code> 会被转换为 <code>&lt;v-text /&gt;</code>。当然，你这个组件也要提前注册到 Vue 中。</p><p>如果你想了解更多 <code>is</code> 属性的资料，请查看<a href="https://cn.vuejs.org/v2/api/#is">官方文档</a>。</p><h2 id="2-"><strong>2. 自定义组件</strong></h2><p>原则上使用第三方组件也是可以的，但建议你最好封装一下。不管是第三方组件还是自定义组件，每个组件所需的属性可能都不一样，所以每个组件数据可以暴露出一个属性 <code>propValue</code> 用于传递值。</p><p>例如 a 组件只需要一个属性，你的 <code>propValue</code> 可以这样写：<code>propValue: 'aaa'</code>。如果需要多个属性，<code>propValue</code> 则可以是一个对象：</p><pre><code class="language-js">propValue: {
  a: 1,
  b: 'text'
}
</code></pre><p>在这个 DEMO 组件库中我定义了三个组件。</p><p>图片组件 <code>Picture</code>：</p><pre><code class="language-html">&lt;template&gt;
    &lt;div style="overflow: hidden"&gt;
        &lt;img :src="propValue"&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
    props: {
        propValue: {
            type: String,
            require: true,
        },
    },
}
&lt;/script&gt;
</code></pre><p>按钮组件 <code>VButton</code>:</p><pre><code class="language-html">&lt;template&gt;
    &lt;button class="v-button"&gt;{{ propValue }}&lt;/button&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
    props: {
        propValue: {
            type: String,
            default: '',
        },
    },
}
&lt;/script&gt;
</code></pre><p>文本组件 <code>VText</code>:</p><pre><code class="language-js">&lt;template&gt;
    &lt;textarea 
        v-if="editMode == 'edit'"
        :value="propValue"
        class="text textarea"
        @input="handleInput"
        ref="v-text"
    &gt;&lt;/textarea&gt;
    &lt;div v-else class="text disabled"&gt;
        &lt;div v-for="(text, index) in propValue.split('\n')" :key="index"&gt;{{ text }}&lt;/div&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
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)
        },
    },
}
&lt;/script&gt;
</code></pre><h2 id="3-"><strong>3. 拖拽</strong></h2><h3 id="-"><strong>从组件列表到画布</strong></h3><p>一个元素如果要设为可拖拽，必须给它添加一个 <code>draggable</code> 属性。另外，在将组件列表中的组件拖拽到画布中，还有两个事件是起到关键作用的：</p><ol><li><code>dragstart</code> 事件，在拖拽刚开始时触发。它主要用于将拖拽的组件信息传递给画布。</li><li><code>drop</code> 事件，在拖拽结束时触发。主要用于接收拖拽的组件信息。</li></ol><p>先来看一下左侧组件列表的代码：</p><pre><code class="language-html">&lt;div @dragstart="handleDragStart" class="component-list"&gt;
    &lt;div v-for="(item, index) in componentList" :key="index" class="list" draggable :data-index="index"&gt;
        &lt;i :class="item.icon"&gt;&lt;/i&gt;
        &lt;span&gt;{{ item.label }}&lt;/span&gt;
    &lt;/div&gt;
&lt;/div&gt;
</code></pre><pre><code class="language-js">handleDragStart(e) {
    e.dataTransfer.setData('index', e.target.dataset.index)
}
</code></pre><p>可以看到给列表中的每一个组件都设置了 <code>draggable</code> 属性。另外，在触发 <code>dragstart</code> 事件时，使用 <code>dataTransfer.setData()</code> 传输数据。再来看一下接收数据的代码：</p><pre><code class="language-html">&lt;div class="content" @drop="handleDrop" @dragover="handleDragOver" @click="deselectCurComponent"&gt;
    &lt;Editor /&gt;
&lt;/div&gt;
</code></pre><pre><code class="language-js">handleDrop(e) {
    e.preventDefault()
    e.stopPropagation()
    const component = deepCopy(componentList[e.dataTransfer.getData('index')])
    this.$store.commit('addComponent', component)
}
</code></pre><p>触发 <code>drop</code> 事件时，使用 <code>dataTransfer.getData()</code> 接收传输过来的索引数据，然后根据索引找到对应的组件数据，再添加到画布，从而渲染组件。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/7bb73413ed40ce0b65091cc710852969.gif" class="kg-image" alt="7bb73413ed40ce0b65091cc710852969" width="600" height="400" loading="lazy"></figure><h3 id="--1"><strong>组件在画布中移动</strong></h3><p>首先需要将画布设为相对定位 <code>position: relative</code>，然后将每个组件设为绝对定位 <code>position: absolute</code>。除了这一点外，还要通过监听三个事件来进行移动：</p><ol><li><code>mousedown</code> 事件，在组件上按下鼠标时，记录组件当前的位置，即 xy 坐标（为了方便讲解，这里使用的坐标轴，实际上 xy 对应的是 css 中的 <code>left</code> 和 <code>top</code>。</li><li><code>mousemove</code> 事件，每次鼠标移动时，都用当前最新的 xy 坐标减去最开始的 xy 坐标，从而计算出移动距离，再改变组件位置。</li><li><code>mouseup</code> 事件，鼠标抬起时结束移动。</li></ol><pre><code class="language-js">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) =&gt; {
        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 = () =&gt; {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/f887aac80d212f92ab20346ba943b3fd.gif" class="kg-image" alt="f887aac80d212f92ab20346ba943b3fd" width="600" height="400" loading="lazy"></figure><h2 id="4-"><strong>4. 删除组件、调整图层层级</strong></h2><h3 id="--2"><strong>改变图层层级</strong></h3><p>由于拖拽组件到画布中是有先后顺序的，所以可以按照数据顺序来分配图层层级。</p><p>例如画布新增了五个组件 abcde，那它们在画布数据中的顺序为 <code>[a, b, c, d, e]</code>，图层层级和索引一一对应，即它们的 <code>z-index</code> 属性值是 01234（后来居上）。用代码表示如下：</p><pre><code class="language-html">&lt;div v-for="(item, index) in componentData" :zIndex="index"&gt;&lt;/div&gt;
</code></pre><p>如果不了解 <code>z-index</code> 属性的，请看一下 <a href="https://developer.mozilla.org/zh-CN/docs/Web/CSS/z-index">MDN 文档</a>。</p><p>理解了这一点之后，改变图层层级就很容易做到了。改变图层层级，即是改变组件数据在 <code>componentData</code> 数组中的顺序。例如有 <code>[a, b, c]</code> 三个组件，它们的图层层级从低到高顺序为 abc（索引越大，层级越高）。</p><p>如果要将 b 组件上移，只需将它和 c 调换顺序即可：</p><pre><code class="language-js">const temp = componentData[1]
componentData[1] = componentData[2]
componentData[2] = temp
</code></pre><p>同理，置顶置底也是一样，例如我要将 a 组件置顶，只需将 a 和最后一个组件调换顺序即可：</p><pre><code class="language-js">const temp = componentData[0]
componentData[0] = componentData[componentData.lenght - 1]
componentData[componentData.lenght - 1] = temp
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/92efcc407b7acfea487da9aea01c03f7.gif" class="kg-image" alt="92efcc407b7acfea487da9aea01c03f7" width="600" height="400" loading="lazy"></figure><h3 id="--3"><strong>删除组件</strong></h3><p>删除组件非常简单，一行代码搞定：<code>componentData.splice(index, 1)</code>。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/6393c54fbcdb64ddd158bc466c97a5e0.gif" class="kg-image" alt="6393c54fbcdb64ddd158bc466c97a5e0" width="600" height="400" loading="lazy"></figure><h2 id="5-"><strong>5. 放大缩小</strong></h2><p>细心的网友可能会发现，点击画布上的组件时，组件上会出现 8 个小圆点。这 8 个小圆点就是用来放大缩小用的。实现原理如下：</p><h4 id="1-shape-shape-8-slot-"><strong>1. 在每个组件外面包一层 <code>Shape</code> 组件，<code>Shape</code> 组件里包含 8 个小圆点和一个 <code>&lt;slot&gt;</code> 插槽，用于放置组件。</strong></h4><pre><code class="language-html">&lt;!--页面组件列表展示--&gt;
&lt;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"
&gt;
    &lt;component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    /&gt;
&lt;/Shape&gt;
</code></pre><p><code>Shape</code> 组件内部结构：</p><pre><code class="language-html">&lt;template&gt;
    &lt;div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown"
    @contextmenu="handleContextMenu"&gt;
        &lt;div
            class="shape-point"
            v-for="(item, index) in (active? pointList : [])"
            @mousedown="handleMouseDownOnPoint(item)"
            :key="index"
            :style="getPointStyle(item)"&gt;
        &lt;/div&gt;
        &lt;slot&gt;&lt;/slot&gt;
    &lt;/div&gt;
&lt;/template&gt;
</code></pre><h4 id="2-8-"><strong>2. 点击组件时，将 8 个小圆点显示出来。</strong></h4><p>起作用的是这行代码 <code>:active="item === curComponent"</code>。</p><h4 id="3--1"><strong>3. 计算每个小圆点的位置。</strong></h4><p>先来看一下计算小圆点位置的代码：</p><pre><code class="language-js">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 =&gt; this.directionKey[m]).join('') + '-resize',
    }

    return style
}
</code></pre><p>计算小圆点的位置需要获取一些信息：</p><ul><li>组件的高度 <code>height</code>、宽度 <code>width</code></li></ul><p>注意，小圆点也是绝对定位的，相对于 <code>Shape</code> 组件。所以有四个小圆点的位置很好确定：</p><ol><li>左上角的小圆点，坐标 <code>left: 0, top: 0</code></li><li>右上角的小圆点，坐标 <code>left: width, top: 0</code></li><li>左下角的小圆点，坐标 <code>left: 0, top: height</code></li><li>右下角的小圆点，坐标 <code>left: width, top: height</code></li></ol><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/93d6230db5e35d4fa5f143885d61120a.png" class="kg-image" alt="93d6230db5e35d4fa5f143885d61120a" width="600" height="400" loading="lazy"></figure><p>另外的四个小圆点需要通过计算间接算出来。例如左边中间的小圆点，计算公式为 <code>left: 0, top: height / 2</code>，其他小圆点同理。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/04a061b5ade3bf7d3641f81a87a1eb69.png" class="kg-image" alt="04a061b5ade3bf7d3641f81a87a1eb69" width="600" height="400" loading="lazy"></figure><h4 id="4--1"><strong>4. 点击小圆点时，可以进行放大缩小操作。</strong></h4><pre><code class="language-js">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) =&gt; {
        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 &gt; 0? newHeight : 0
        pos.width = newWidth &gt; 0? newWidth : 0
        pos.left = left + (hasL? disX : 0)
        pos.top = top + (hasT? disY : 0)
        this.$store.commit('setShapeStyle', pos)
    }

    const up = () =&gt; {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
        needSave &amp;&amp; this.$store.commit('recordSnapshot')
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}
</code></pre><p>它的原理是这样的：</p><ol><li>点击小圆点时，记录点击的坐标 xy。</li><li>假设我们现在向下拖动，那么 y 坐标就会增大。</li><li>用新的 y 坐标减去原来的 y 坐标，就可以知道在纵轴方向的移动距离是多少。</li><li>最后再将移动距离加上原来组件的高度，就可以得出新的组件高度。</li><li>如果是正数，说明是往下拉，组件的高度在增加。如果是负数，说明是往上拉，组件的高度在减少。</li></ol><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/2103d0dec08064acf5ac36e39c30a2a6.gif" class="kg-image" alt="2103d0dec08064acf5ac36e39c30a2a6" width="600" height="400" loading="lazy"></figure><h2 id="6-"><strong>6. 撤消、重做</strong></h2><p>撤销重做的实现原理其实挺简单的，先看一下代码：</p><pre><code class="language-js">snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
        
undo(state) {
    if (state.snapshotIndex &gt;= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

redo(state) {
    if (state.snapshotIndex &lt; 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 &lt; state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
},
</code></pre><p>用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 <code>push()</code> 操作，将当前的编辑器数据推入 <code>snapshotData</code> 数组，并增加快照索引 <code>snapshotIndex</code>。目前以下几个动作会触发保存快照操作：</p><ul><li>新增组件</li><li>删除组件</li><li>改变图层层级</li><li>拖动组件结束时<br>…</li></ul><h4 id="--4"><strong>撤销</strong></h4><p>假设现在 <code>snapshotData</code> 保存了 4 个快照。即 <code>[a, b, c, d]</code>，对应的快照索引为 3。如果这时进行了撤销操作，我们需要将快照索引减 1，然后将对应的快照数据赋值给画布。</p><p>例如当前画布数据是 d，进行撤销后，索引 -1，现在画布的数据是 c。</p><h4 id="--5"><strong>重做</strong></h4><p>明白了撤销，那重做就很好理解了，就是将快照索引加 1，然后将对应的快照数据赋值给画布。</p><p>不过还有一点要注意，就是在撤销操作中进行了新的操作，要怎么办呢？有两种解决方案：</p><ol><li>新操作替换当前快照索引后面所有的数据。还是用刚才的数据 <code>[a, b, c, d]</code> 举例，假设现在进行了两次撤销操作，快照索引变为 1，对应的快照数据为 b，如果这时进行了新的操作，对应的快照数据为 e。那 e 会把 cd 顶掉，现在的快照数据为 <code>[a, b, e]</code>。</li><li>不顶掉数据，在原来的快照中新增一条记录。用刚才的例子举例，e 不会把 cd 顶掉，而是在 cd 之前插入，即快照数据变为 <code>[a, b, e, c, d]</code>。</li></ol><p>我采用的是第一种方案。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/0f395c0cd09af3cce579296acda7578f.gif" class="kg-image" alt="0f395c0cd09af3cce579296acda7578f" width="600" height="400" loading="lazy"></figure><h2 id="7-"><strong>7. 吸附</strong></h2><p>什么是吸附？就是在拖拽组件时，如果它和另一个组件的距离比较接近，就会自动吸附在一起。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/259b4a684f6be325840958818996dc44.gif" class="kg-image" alt="259b4a684f6be325840958818996dc44" width="600" height="400" loading="lazy"></figure><p>吸附的代码大概在 300 行左右，建议自己打开源码文件看（文件路径：<code>src\components\Editor\MarkLine.vue</code>）。这里不贴代码了，主要说说原理是怎么实现的。</p><h3 id="--6"><strong>标线</strong></h3><p>在页面上创建 6 条线，分别是三横三竖。这 6 条线的作用是对齐，它们什么时候会出现呢？</p><ol><li>上下方向的两个组件左边、中间、右边对齐时会出现竖线</li><li>左右方向的两个组件上边、中间、下边对齐时会出现横线</li></ol><p>具体的计算公式主要是根据每个组件的 xy 坐标和宽度高度进行计算的。例如要判断 ab 两个组件的左边是否对齐，则要知道它们每个组件的 x 坐标；如果要知道它们右边是否对齐，除了要知道 x 坐标，还要知道它们各自的宽度。</p><pre><code class="language-js">// 左对齐的条件
a.x == b.x

// 右对齐的条件
a.x + a.width == b.x + b.width
</code></pre><p>在对齐的时候，显示标线。</p><p>另外还要判断 ab 两个组件是否“足够”近。如果足够近，就吸附在一起。是否足够近要靠一个变量来判断：</p><pre><code class="language-js">diff: 3, // 相距 dff 像素将自动吸附
</code></pre><p>小于等于 <code>diff</code> 像素则自动吸附。</p><h3 id="--7"><strong>吸附</strong></h3><p><strong><strong>吸附效果是怎么实现的呢？</strong></strong></p><p>假设现在有 ab 组件，a 组件坐标 xy 都是 0，宽高都是 100。现在假设 a 组件不动，我们正在拖拽 b 组件。当把 b 组件拖到坐标为 <code>x: 0, y: 103</code> 时，由于 <code>103 - 100 &lt;= 3(diff)</code>，所以可以判定它们已经接近得足够近。这时需要手动将 b 组件的 y 坐标值设为 100，这样就将 ab 组件吸附在一起了。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/4c57e644b5498fcd3a2277b9183cc020.gif" class="kg-image" alt="4c57e644b5498fcd3a2277b9183cc020" width="600" height="400" loading="lazy"></figure><h3 id="--8"><strong>优化</strong></h3><p>在拖拽时如果 6 条标线都显示出来会不太美观。所以我们可以做一下优化，在纵横方向上最多只同时显示一条线。实现原理如下：</p><ol><li>a 组件在左边不动，我们拖着 b 组件往 a 组件靠近。</li><li>这时它们最先对齐的是 a 的右边和 b 的左边，所以只需要一条线就够了。</li><li>如果 ab 组件已经靠近，并且 b 组件继续往左边移动，这时就要判断它们俩的中间是否对齐。</li><li>b 组件继续拖动，这时需要判断 a 组件的左边和 b 组件的右边是否对齐，也是只需要一条线。</li></ol><p>可以发现，关键的地方是我们要知道两个组件的方向。即 ab 两个组件靠近，我们要知道到底 b 是在 a 的左边还是右边。</p><p>这一点可以通过鼠标移动事件来判断，之前在讲解拖拽的时候说过，<code>mousedown</code> 事件触发时会记录起点坐标。所以每次触发 <code>mousemove</code> 事件时，用当前坐标减去原来的坐标，就可以判断组件方向。例如 x 方向上，如果 <code>b.x - a.x</code> 的差值为正，说明是 b 在 a 右边，否则为左边。</p><pre><code class="language-js">// 触发元素移动事件，用于显示标线、吸附功能
// 后面两个参数代表鼠标移动方向
// currY - startY &gt; 0 true 表示向下移动 false 表示向上移动
// currX - startX &gt; 0 true 表示向右移动 false 表示向左移动
eventBus.$emit('move', this.$el, currY - startY &gt; 0, currX - startX &gt; 0)
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/a4fa5b52c403f6a1b85126b14fb639ff.gif" class="kg-image" alt="a4fa5b52c403f6a1b85126b14fb639ff" width="600" height="400" loading="lazy"></figure><h2 id="8-"><strong>8. 组件属性设置</strong></h2><p>每个组件都有一些通用属性和独有的属性，我们需要提供一个能显示和修改属性的地方。</p><pre><code class="language-js">// 每个组件数据大概是这样
{
    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: '',
    },
}
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/8b4b07c5b98030d887b0ae405f7e6f08.png" class="kg-image" alt="8b4b07c5b98030d887b0ae405f7e6f08" width="600" height="400" loading="lazy"></figure><p>我定义了一个 <code>AttrList</code> 组件，用于显示每个组件的属性。</p><pre><code class="language-html">&lt;template&gt;
    &lt;div class="attr-list"&gt;
        &lt;el-form&gt;
            &lt;el-form-item v-for="(key, index) in styleKeys" :key="index" :label="map[key]"&gt;
                &lt;el-color-picker v-if="key == 'borderColor'" v-model="curComponent.style[key]"&gt;&lt;/el-color-picker&gt;
                &lt;el-color-picker v-else-if="key == 'color'" v-model="curComponent.style[key]"&gt;&lt;/el-color-picker&gt;
                &lt;el-color-picker v-else-if="key == 'backgroundColor'" v-model="curComponent.style[key]"&gt;&lt;/el-color-picker&gt;
                &lt;el-select v-else-if="key == 'textAlign'" v-model="curComponent.style[key]"&gt;
                    &lt;el-option
                        v-for="item in options"
                        :key="item.value"
                        :label="item.label"
                        :value="item.value"
                    &gt;&lt;/el-option&gt;
                &lt;/el-select&gt;
                &lt;el-input type="number" v-else v-model="curComponent.style[key]" /&gt;
            &lt;/el-form-item&gt;
            &lt;el-form-item label="内容" v-if="curComponent &amp;&amp; curComponent.propValue &amp;&amp; !excludes.includes(curComponent.component)"&gt;
                &lt;el-input type="textarea" v-model="curComponent.propValue" /&gt;
            &lt;/el-form-item&gt;
        &lt;/el-form&gt;
    &lt;/div&gt;
&lt;/template&gt;
</code></pre><p>代码逻辑很简单，就是遍历组件的 <code>style</code> 对象，将每一个属性遍历出来。并且需要根据具体的属性用不同的组件显示出来，例如颜色属性，需要用颜色选择器显示；数值类的属性需要用 <code>type=number</code> 的 input 组件显示等等。</p><p>为了方便用户修改属性值，我使用 <code>v-model</code> 将组件和值绑定在一起。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/9298f902bc59f902668b7a52a637d257.gif" class="kg-image" alt="9298f902bc59f902668b7a52a637d257" width="600" height="400" loading="lazy"></figure><h2 id="9-"><strong>9. 预览、保存代码</strong></h2><p>预览和编辑的渲染原理是一样的，区别是不需要编辑功能。所以只需要将原先渲染组件的代码稍微改一下就可以了。</p><pre><code class="language-html">&lt;!--页面组件列表展示--&gt;
&lt;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"
&gt;
    &lt;component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    /&gt;
&lt;/Shape&gt;
</code></pre><p>经过刚才的介绍，我们知道 <code>Shape</code> 组件具备了拖拽、放大缩小的功能。现在只需要将 <code>Shape</code> 组件去掉，外面改成套一个普通的 DIV 就可以了（其实不用这个 DIV 也行，但为了绑定事件这个功能，所以需要加上）。</p><pre><code class="language-html">&lt;!--页面组件列表展示--&gt;
&lt;div v-for="(item, index) in componentData" :key="item.id"&gt;
    &lt;component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    /&gt;
&lt;/div&gt;
</code></pre><p>保存代码的功能也特别简单，只需要保存画布上的数据 <code>componentData</code> 即可。保存有两种选择：</p><ol><li>保存到服务器</li><li>本地保存</li></ol><p>在 DEMO 上我使用的 <code>localStorage</code> 保存在本地。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/bca0dbef1e56007494955711b264c47b.gif" class="kg-image" alt="bca0dbef1e56007494955711b264c47b" width="600" height="400" loading="lazy"></figure><h2 id="10-"><strong>10. 绑定事件</strong></h2><p>每个组件有一个 <code>events</code> 对象，用于存储绑定的事件。目前我只定义了两个事件：</p><ul><li>alert 事件</li><li>redirect 事件</li></ul><pre><code class="language-js">// 编辑器自定义事件
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,
}
</code></pre><p>不过不能在编辑的时候触发，可以在预览的时候触发。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/f14066ef5985fdb885ecb760814bdc20.gif" class="kg-image" alt="f14066ef5985fdb885ecb760814bdc20" width="600" height="400" loading="lazy"></figure><h3 id="--9"><strong>添加事件</strong></h3><p>通过 <code>v-for</code> 指令将事件列表渲染出来：</p><pre><code class="language-html">&lt;el-tabs v-model="eventActiveName"&gt;
    &lt;el-tab-pane v-for="item in eventList" :key="item.key" :label="item.label" :name="item.key" style="padding: 0 20px"&gt;
        &lt;el-input v-if="item.key == 'redirect'" v-model="item.param" type="textarea" placeholder="请输入完整的 URL" /&gt;
        &lt;el-input v-if="item.key == 'alert'" v-model="item.param" type="textarea" placeholder="请输入要 alert 的内容" /&gt;
        &lt;el-button style="margin-top: 20px;" @click="addEvent(item.key, item.param)"&gt;确定&lt;/el-button&gt;
    &lt;/el-tab-pane&gt;
&lt;/el-tabs&gt;
</code></pre><p>选中事件时将事件添加到组件的 <code>events</code> 对象。</p><h3 id="--10"><strong>触发事件</strong></h3><p>预览或真正渲染页面时，也需要在每个组件外面套一层 DIV，这样就可以在 DIV 上绑定一个点击事件，点击时触发我们刚才添加的事件。</p><pre><code class="language-html">&lt;template&gt;
    &lt;div @click="handleClick"&gt;
        &lt;component
            class="conponent"
            :is="config.component"
            :style="getStyle(config.style)"
            :propValue="config.propValue"
        /&gt;
    &lt;/div&gt;
&lt;/template&gt;
</code></pre><pre><code class="language-js">handleClick() {
    const events = this.config.events
    // 循环触发绑定的事件
    Object.keys(events).forEach(event =&gt; {
        this[event](events[event])
    })
}
</code></pre><h2 id="11-"><strong>11. 绑定动画</strong></h2><p>动画和事件的原理是一样的，先将所有的动画通过 <code>v-for</code> 指令渲染出来，然后点击动画将对应的动画添加到组件的 <code>animations</code> 数组里。同事件一样，执行的时候也是遍历组件所有的动画并执行。</p><p>为了方便，我们使用了 <a href="https://animate.style/">animate.css</a> 动画库。</p><pre><code class="language-js">// main.js
import '@/styles/animate.css'
</code></pre><p>现在我们提前定义好所有的动画数据：</p><pre><code class="language-js">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' },
        ],
    },
]
</code></pre><p>然后用 <code>v-for</code> 指令渲染出来动画列表。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/5b1bfb02b5c6f8c7a2dcfa34563db0af.gif" class="kg-image" alt="5b1bfb02b5c6f8c7a2dcfa34563db0af" width="600" height="400" loading="lazy"></figure><h3 id="--11"><strong>添加动画</strong></h3><pre><code class="language-html">&lt;el-tabs v-model="animationActiveName"&gt;
    &lt;el-tab-pane v-for="item in animationClassData" :key="item.label" :label="item.label" :name="item.label"&gt;
        &lt;el-scrollbar class="animate-container"&gt;
            &lt;div
                class="animate"
                v-for="(animate, index) in item.children"
                :key="index"
                @mouseover="hoverPreviewAnimate = animate.value"
                @click="addAnimation(animate)"
            &gt;
                &lt;div :class="[hoverPreviewAnimate === animate.value &amp;&amp; animate.value + ' animated']"&gt;
                    {{ animate.label }}
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/el-scrollbar&gt;
    &lt;/el-tab-pane&gt;
&lt;/el-tabs&gt;
</code></pre><p>点击动画将调用 <code>addAnimation(animate)</code> 将动画添加到组件的 <code>animations</code> 数组。</p><h3 id="--12"><strong>触发动画</strong></h3><p>运行动画的代码：</p><pre><code class="language-js">export default async function runAnimation($el, animations = []) {
    const play = (animation) =&gt; new Promise(resolve =&gt; {
        $el.classList.add(animation.value, 'animated')
        const removeAnimation = () =&gt; {
            $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 &lt; len; i++) {
        await play(animations[i])
    }
}
</code></pre><p>运行动画需要两个参数：组件对应的 DOM 元素（在组件使用 <code>this.$el</code> 获取）和它的动画数据 <code>animations</code>。并且需要监听 <code>animationend</code> 事件和 <code>animationcancel</code> 事件：一个是动画结束时触发，一个是动画意外终止时触发。</p><p>利用这一点再配合 <code>Promise</code> 一起使用，就可以逐个运行组件的每个动画了。</p><h2 id="12-psd"><strong>12. 导入 PSD</strong></h2><p>由于时间关系，这个功能我还没做。现在简单的描述一下怎么做这个功能。那就是使用 <a href="https://github.com/meltingice/psd.js#readme">psd.js</a> 库，它可以解析 PSD 文件。</p><p>使用 <code>psd</code> 库解析 PSD 文件得出的数据如下：</p><pre><code class="language-js">{ 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: [] } } }
</code></pre><p>从以上代码可以发现，这些数据和 css 非常像。根据这一点，只需要写一个转换函数，将这些数据转换成我们组件所需的数据，就能实现 PSD 文件转成渲染组件的功能。目前 <a href="https://github.com/huangwei9527/quark-h5">quark-h5</a> 和 <a href="https://github.com/ly525/luban-h5">luban-h5</a> 都是这样实现的 PSD 转换功能。</p><h2 id="13-"><strong>13. 手机模式</strong></h2><p>由于画布是可以调整大小的，我们可以使用 iphone6 的分辨率来开发手机页面。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/a9ed24b3e50e3c650615fbfe76698a2c.png" class="kg-image" alt="a9ed24b3e50e3c650615fbfe76698a2c" width="600" height="400" loading="lazy"></figure><p>这样开发出来的页面也可以在手机下正常浏览，但可能会有样式偏差。因为我自定义的三个组件是没有做适配的，如果你需要开发手机页面，那自定义组件必须使用移动端的 UI 组件库。或者自己开发移动端专用的自定义组件。</p><h2 id="--13"><strong>总结</strong></h2><p>由于 DEMO 的代码比较多，所以在讲解每一个功能点时，我只把关键代码贴上来。所以大家会发现 DEMO 的源码和我贴上来的代码会有些区别，请不必在意。</p><p>另外，DEMO 的样式也比较简陋，主要是最近事情比较多，没太多时间写好看点，请见谅。</p><h2 id="--14"><strong>参考资料</strong></h2><ul><li><a href="https://github.com/think2011/ref-line">ref-line</a></li><li><a href="https://github.com/huangwei9527/quark-h5">quark-h5</a></li><li><a href="https://github.com/ly525/luban-h5">luban-h5</a></li><li><a href="https://www.eqxiu.com/workstage/fast-make">易企秀</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Document/drag_event">drag 事件</a></li></ul> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
