16

This article is the fourth in the visual drag and drop series. Compared with the previous three articles, this feature has slightly fewer points. There are five points in total:

  1. SVG components
  2. Dynamic Properties Panel
  3. Data source (interface request)
  4. Component linkage
  5. Components are loaded on demand

If you don't know much about my previous series of articles, it is recommended to read these three articles first, and then read this article (otherwise there is no context and it is not easy to understand):

Also attach the project, online DEMO address:

SVG components

At present, the custom components provided in the project all support free enlargement and reduction, but they have one thing in common - they are all regular shapes. That is to say, they can be enlarged or reduced by directly changing the width and height, and no other processing is required. But irregular shapes are different, such as a five-pointed star, you have to consider how to change the size proportionally when zooming in and out. In the end, I adopted the svg solution (I also considered using iconfont to implement it, but it was flawed and gave up). Let's take a look at the specific implementation details.

Draw a pentagram with SVG

Suppose we need to draw a 100 * 100 five-pointed star, its code is as follows:

 <svg 
    version="1.1" 
    baseProfile="full" 
    xmlns="http://www.w3.org/2000/svg"
>
    <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"
    ></polygon>
</svg>

Attributes such as version and namespace on svg are not very important and can be ignored for now. The focus is on the polygon element, which defines a svg by 一组首尾相连的直线线段构成的闭合多边形形状 , the last point is connected to the first point. That is to say, the polygon consists of a series of coordinate points, and the connected points will be automatically connected. The points property of polygon is used to represent a series of coordinate points of the polygon, each coordinate point consists of xy coordinates, and each coordinate point is separated by , comma.

在这里插入图片描述

The picture above is a five-pointed star drawn with svg, which consists of ten coordinate 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 . Since this is a 100*100 five-pointed star, we can easily calculate the proportion of each coordinate point in the five-pointed star (coordinate system) based on the value of each coordinate point. For example, if the first point is p1( 50,0 ), then its xy coordinate ratio is 50%, 0 ; the second point p2( 62.5,37.5 ), corresponding to yes 62.5%, 37.5%

 // 五角星十个坐标点的比例集合
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],
]

Now that you know the proportions of the five-pointed star, it is easy to draw five-pointed stars of other sizes. We only need to give the specific value of each coordinate point in equal proportions every time we zoom in and out of the five-pointed star and change its size.

 <div class="svg-star-container">
    <svg
        version="1.1"
        baseProfile="full"
        xmlns="http://www.w3.org/2000/svg"
    >
        <polygon
            ref="star"
            :points="points"
            :stroke="element.style.borderColor"
            :fill="element.style.backgroundColor"
            stroke-width="1"
        />
    </svg>
    <v-text :prop-value="element.propValue" :element="element" />
</div>

<script>
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 => width * point[0] + ' ' + height * point[1])
    this.points = coordinatePoints.toString() // 得出五角星的 points 属性数据
}
</script>

在这里插入图片描述

Other SVG components

Similarly, to draw other types of svg components, we only need to know the proportion of their coordinate points. If you don't know how to draw an svg, you can search it online and find a usable svg code (the svg code of this five-pointed star is found on the Internet). Then calculate the proportion of each coordinate point, convert it into the form of a decimal point, and finally substitute these data into the drawPolygon() function provided above. For example, the code to draw a triangle is as follows:

 function drawTriangle(width, height) {
    const points = [
        [0.5, 0.05],
        [1, 0.95],
        [0, 0.95],
    ]

    const coordinatePoints = points.map(point => width * point[0] + ' ' + height * point[1])
    this.points = coordinatePoints.toString() // 得出三角形的 points 属性数据
}

在这里插入图片描述

Dynamic Properties Panel

Currently all custom components' property panels share the same AttrList component. So the disadvantage is obvious, you need to write a lot of if statements here, because different components have different properties. For example, the rectangle component has the content property, but the image does not. A different property requires an if statement.

 <el-form-item v-if="name === 'rectShape'" label="内容">
   <el-input />
</el-form-item>
<!-- 其他属性... -->

Fortunately, the solution to this problem is not difficult. In the first article of this series, I explained how to dynamically render custom components:

 <component :is="item.component"></component> <!-- 动态渲染组件 -->

In each custom component's data structure there is a component property, which is the name the component is registered with in Vue. Therefore, the properties panel of each custom component can be made dynamic like the component itself (using the component property):

 <!-- 右侧属性列表 -->
<section class="right">
    <el-tabs v-if="curComponent" v-model="activeName">
        <el-tab-pane label="属性" name="attr">
            <component :is="curComponent.component + 'Attr'" /> <!-- 动态渲染属性面板 -->
        </el-tab-pane>
        <el-tab-pane label="动画" name="animation" style="padding-top: 20px;">
            <AnimationList />
        </el-tab-pane>
        <el-tab-pane label="事件" name="events" style="padding-top: 20px;">
            <EventList />
        </el-tab-pane>
    </el-tabs>
    <CanvasAttr v-else></CanvasAttr>
</section>

At the same time, the directory structure of custom components also needs to be adjusted. The original directory structure is:

 - VText.vue
- Picture.vue
...

After adjustment it becomes:

 - VText
    - Attr.vue <!-- 组件的属性面板 -->
    - Component.vue <!-- 组件本身 -->
- Picture
    - Attr.vue
    - Component.vue

Each component now contains the component itself and its properties panel. After the transformation, the image property panel code is also more streamlined:

 <template>
    <div class="attr-list">
        <CommonAttr></CommonAttr> <!-- 通用属性 -->
        <el-form>
            <el-form-item label="镜像翻转">
                <div style="clear: both;">
                    <el-checkbox v-model="curComponent.propValue.flip.horizontal" label="horizontal">水平翻转</el-checkbox>
                    <el-checkbox v-model="curComponent.propValue.flip.vertical" label="vertical">垂直翻转</el-checkbox>
                </div>
            </el-form-item>
        </el-form>
    </div>
</template>

In this way, both the component and the corresponding property panel become dynamic. It is very convenient to add properties to a custom component separately in the future.

在这里插入图片描述

Data source (interface request)

Some components have the need to dynamically load data, so a Request public attribute component is specially added to request data. When a custom component has the request property, it will render the relevant content of the interface request on the property panel. So far, there are two public components of the property panel:

 -common
    - Request.vue <!-- 接口请求 -->
    - CommonAttr.vue <!-- 通用样式 -->
 // 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: '',
    },
}

在这里插入图片描述
As can be seen from the above animation, the method parameters of the api request can be modified manually. But how to control the returned data to be assigned to a property of the component? This can pass the entire data object of the component obj and the attribute to be modified key as parameters when the request is made. When the data is returned, it can be used directly obj[key] = data to modify the data.

 // 第二个参数是要修改数据的父对象,第三个参数是修改数据的 key,第四个数据修改数据的类型
this.cancelRequest = request(this.request, this.element, 'propValue', 'string')

Component linkage

Component linkage: When one component triggers an event, another component will receive a notification and take corresponding actions.

在这里插入图片描述
The rectangle in the animation above listens to the suspension events of the two buttons below. The first button triggers the suspension and broadcasts the event, and the rectangle executes the callback to rotate and move to the right; the second button, on the contrary, rotates and moves to the left.

To achieve this function, first add a new attribute to the custom component linkage to record all the components to be linked:

 {
    // 组件的其他属性...
    linkage: {
         duration: 0, // 过渡持续时间
         data: [ // 组件联动
             {
                 id: '', // 联动的组件 id
                 label: '', // 联动的组件名称
                 event: '', // 监听事件
                 style: [{ key: '', value: '' }], // 监听的事件触发时,需要改变的属性
             },
         ],
     }
}

The corresponding properties panel is:

在这里插入图片描述
Component linkage is essentially the use of the subscribe/publish model, and each component will traverse all the components it listens to when rendering.

event listener

 <script>
import eventBus from '@/utils/eventBus'

export default {
    props: {
        linkage: {
            type: Object,
            default: () => {},
        },
        element: {
            type: Object,
            default: () => {},
        },
    },
    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 => {
                item.style.forEach(e => {
                    if (e.key) {
                        this.element.style[e.key] = e.value
                    }
                })
            })
        },

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

        onHover(componentId) {
            const data = this.linkage.data.filter(item => item.id === componentId && item.event === 'v-hover')
            this.changeStyle(data)
        },
    },
}
</script>

As can be seen from the above code:

  1. When each custom component is initialized, it will listen to two events v-click v-hover (currently only two events of click and suspension)
  2. When the event callback function is triggered, it will receive a parameter - the id of the component that emits the event (for example, multiple components have triggered a click event, you need to judge whether it is a component that is listening by yourself based on the id)
  3. Finally, modify the corresponding properties

event trigger

 <template>
    <div @click="onClick" @mouseenter="onMouseEnter">
        <component
            :is="config.component"
            ref="component"
            class="component"
            :style="getStyle(config.style)"
            :prop-value="config.propValue"
            :element="config"
            :request="config.request"
            :linkage="config.linkage"
        />
    </div>
</template>

<script>
import eventBus from '@/utils/eventBus'

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

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

        onMouseEnter() {
            eventBus.$emit('v-hover', this.config.id)
        },
    },
}
</script>

As can be seen from the above code, when rendering components, the outermost layer of each component listens to click mouseenter events. When these events are triggered, eventBus will trigger the corresponding events. event ( v-click or v-hover ), and pass the current component id as a parameter.

Finally, the overall logic is again:

  1. a component listens to the native event click mouseenter
  2. The user clicks or moves the mouse on the component to trigger the native event click or mouseenter
  3. The event callback function then uses eventBus to trigger the v-click or v-hover event
  4. The b component that listens to these two events receives the notification and then modifies the relevant properties of the b component (such as the x coordinate and rotation angle of the above rectangle)

Components are loaded on demand

At present, the project itself does not do on-demand loading, but I wrote the implementation plan in the form of text, which is almost the same.

The first step, pull out

The first step is to get all the custom components out and store them separately. It is recommended to use the monorepo method for storage, and all components are placed in a warehouse. Each package is a component that can be packaged separately.

 - node_modules
- packages
    - v-text # 一个组件就是一个包 
    - v-button
    - v-table
- package.json
- lerna.json

The second step, packing

It is recommended that each component be packaged into a js file, such as bundle.js. After packaging, directly call the upload interface and store it on the server (you can also publish it to npm). Each component has a unique id. Every time the front end renders the component, it requests the server for the URL of the component resource through this component id.

The third step is to dynamically load components

There are two ways to dynamically load components:

  1. import()
  2. <script> label

The first way is easier to implement:

 const name = 'v-text' // 组件名称
const component = await import('https://xxx.xxx/bundile.js')
Vue.component(name, component)

But there is a little problem with compatibility. If you want to support some old browsers (such as IE), you can use the <script> tag to load:

 function loadjs(url) {
    return new Promise((resolve, reject) => {
        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])

In order to support these two loading methods at the same time, you need to determine whether the browser supports ES6 when loading components. If it is supported, use the first method, if not, use the second method:

 function isSupportES6() {
    try {
        new Function('const fn = () => {};')
    } catch (error) {
        return false
    }

    return true
}

Finally, packaging should also be compatible with both loading methods:

 import VText from './VText.vue'

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

export default VText

At the same time, export the component and hang the component under the window.

Other small optimizations

Image mirror flip

在这里插入图片描述
Image mirroring needs to be implemented using canvas, mainly using two methods of canvas translate() scale() . Suppose we want to perform a horizontal mirror flip on a 100*100 image, its code is as follows:

 <canvas width="100" height="100"></canvas>

<script>
    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 = () => ctx.drawImage(img, 0, 0, width, height)

    // 水平翻转
    setTimeout(() => {
        // 清除图片
        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)
</script>

ctx.translate(width, 0) This line of code means to move the x coordinate of the image width pixels forward, so after the translation, the image is just outside the canvas. Then use ctx.scale(-1, 1) to flip the picture horizontally, and you can get a horizontally flipped picture.

在这里插入图片描述

Vertical flip is the same principle, but the parameters are different:

 // 原来水平翻转是 ctx.translate(width, 0)
ctx.translate(0, height) 
// 原来水平翻转是 ctx.scale(-1, 1)
ctx.scale(1, -1)

Live component list

Each component in the canvas is hierarchical, but the specific level of each component is not displayed in real time. Hence the functionality of this live component list.

This function is not difficult to implement, its principle is the same as the canvas rendering component, except that this list only needs to render the icon and name.

 <div class="real-time-component-list">
    <div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: index === curComponentIndex }"
        @click="onClick(index)"
    >
        <span class="iconfont" :class="'icon-' + getComponent(index).icon"></span>
        <span>{{ getComponent(index).label }}</span>
    </div>
</div>

But it should be noted that in the array of component data, the later the component level is higher. So if the data index of the array is not processed, the scene that the user sees is like this ( assuming the order of adding components is text, button, picture ):

在这里插入图片描述
From the user's point of view, the image with the highest level is ranked last in the live list. This is different from our usual perception. So, we need to make a reverse() flip the component data. For example, the index of the text component is 0, the lowest level, it should be displayed at the bottom. Then every time we display it in the real-time list, we can convert it through the following code to get the flipped index, and then render it. This sort of sorting looks more comfortable.

 <div class="real-time-component-list">
    <div
        v-for="(item, index) in componentData"
        :key="index"
        class="list"
        :class="{ actived: transformIndex(index) === curComponentIndex }"
        @click="onClick(transformIndex(index))"
    >
        <span class="iconfont" :class="'icon-' + getComponent(index).icon"></span>
        <span>{{ getComponent(index).label }}</span>
    </div>
</div>

<script>
function getComponent(index) {
    return componentData[componentData.length - 1 - index]
}

function transformIndex(index) {
    return componentData.length - 1 - index
}
</script>

在这里插入图片描述
After the conversion, the picture with the highest level is ranked at the top in the real-time list, perfect!

Summarize

So far, the fourth article in the visual drag and drop series has ended, and it has been more than a year since the release of the previous series of articles (February 15, 2021). I did not expect this project to be so popular, and it has been recognized by many netizens in just one year. So I hope the fourth article in this series will be as helpful as before, thank you again!

Finally , I would like to recommend myself. I have five years + front-end, and have experience in infrastructure and leading a team. Does anyone have any recommendations for front-end positions in Beijing and Tianjin? If there is, please leave a message in the comment area, or private message to help push it, thank you!


谭光志
6.9k 声望13.1k 粉丝