Plug in development practice of a simple annotation Library

Recently, when refining a function, I found that there are too many configurable items. If they are all coupled together, first, the code is not easy to maintain and the scalability is not good. Secondly, if I do not need the function, it will bring volume redundancy. Considering the popularity of plug-in, I made a small attempt.

First, let's introduce the functions of this library. A simple function allows you to mark an area range on an area, usually a picture, and then return the vertex coordinates:

Don't talk much, open up.

Plug in design

Plugin I understand is a function fragment. There are various ways of organizing code. Functions or classes, libraries or frameworks may have their own design. Generally, you need to expose a specified interface. Then, when you call plug-ins, you will also inject some interfaces or states to expand the functions you need.

I choose to organize the plug-in code in the form of functions, so a plug-in is an independent function.

First, the entry of the library is a class:

class Markjs {}

Plug ins need to be registered first, such as the common vue:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

Referring to this method, our plug-ins are registered as follows:

import Markjs from 'markjs'
import imgPlugin from 'markjs/src/plugins/img'
Markjs.use(imgPlugin)

First, let's analyze what the use should do. Because the plug-in is a function, is it OK to call the function directly in the use? In fact, it's not possible here, because Markjs is a class. When using it, you need new Markjs to create an instance. The variables and methods that the plug-in needs to access can only be accessed after instantiation. Therefore, use only needs to do a simple collection. The call of plug-in functions is carried out at the same time of instantiation. Of course, If your plug-in just adds some mixin s or methods to the prototype like vue, it can be called directly:

class Markjs {
    // Plug in list
    static pluginList = []

    // Install plug-ins
    static use(plugin, index = -1) {
        if (!plugin) {
            return Markjs
        }
        if (plugin.used) {
            return Markjs
        }
        plugin.used = true
        if (index === -1) {
            Markjs.pluginList.push(plugin)
        } else {
            Markjs.pluginList.splice(index, 0, plugin)
        }
        return Markjs
    }
}

The code is very simple. A static attribute pluginList is defined to store plug-ins. The static method use is used to collect plug-ins. An attribute will be added to the plug-ins to judge whether they have been added and avoid repeated addition. Secondly, the second parameter is allowed to control where the plug-ins are to be inserted, because some plug-ins may have sequence requirements. You can make chain calls by returning Markjs.

After that, when instantiating, traverse and call the plug-in function:

class Markjs {
    constructor(opt = {}) {
        //...
        // Call plug-in
        this.usePlugins()
    }
    
    // Call plug-in
    usePlugins() {
        let index = 0
        let len = Markjs.pluginList.length
        let loopUse = () => {
            if (index >= len) {
                return
            }
            let cur = Markjs.pluginList[index]
            cur(this, utils).then(() => {
                index++
                loopUse()
            })
        }
        loopUse()
    }
}

The plug-in will be called at the end of the instance creation. It can be seen that this is not a simple circular call, but a chain call through promise. The reason for this is that the initialization of some plug-ins may be asynchronous. For example, the image loading in the image plug-in is an asynchronous process, so the corresponding plug-in function must return a promise:

export default function ImgPlugin(instance) {
    let _resolve = null
    let promise = new Promise((resolve) => {
        _resolve = resolve
    })
    
    // Plug in logic
    setTimeout(() => {
        _resolve()
    },1000)
    
    return promise
}

Here, the simple plug-in system is completed. Instance is the instance object created. You can access its variables, methods, or listen to events you need.

Markjs

Because plug-in has been selected, the core functions, which refer to the related functions of annotation, are also considered as a plug-in. Therefore, Markjs class only does some variable definition, event monitoring, distribution and initialization.

The annotation function is implemented by canvas, so the main logic is to listen to some mouse events to call the canvas drawing context for drawing. A simple subscription and publication mode is used for event distribution.

class Markjs {
    constructor(opt = {}) {
        // Configuration parameter merge processing
        // Variable definition
        this.observer = new Observer()// Publish subscribe object
        // initialization
        // Binding event
        // Call plug-in
    }
}

The above is all that the Markjs class does. One thing to do during initialization is to create a canvas element, then get the drawing context and directly look at the binding events. The functions of this library require mouse click, double click, press, move, release and other events:

class Markjs {
    bindEvent() {
        this.canvasEle.addEventListener('click', this.onclick)
        this.canvasEle.addEventListener('mousedown', this.onmousedown)
        this.canvasEle.addEventListener('mousemove', this.onmousemove)
        window.addEventListener('mouseup', this.onmouseup)
        this.canvasEle.addEventListener('mouseenter', this.onmouseenter)
        this.canvasEle.addEventListener('mouseleave', this.onmouseleave)
    }
}

Although there is an ondbllick event to listen to, the click event will also be triggered when double clicking, so it is impossible to distinguish between double clicking and double clicking. Generally, double clicking is simulated through the click event. Of course, you can also listen to double clicking events to simulate click events. One reason why you don't do this is that you don't know the double clicking interval of the system, Therefore, it is difficult to determine the time interval of the timer:

class Markjs {
    // Click event
    onclick(e) {
        if (this.clickTimer) {
            clearTimeout(this.clickTimer)
            this.clickTimer = null
        }

        // Click the event to delay the trigger by 200ms
        this.clickTimer = setTimeout(() => {
            this.observer.publish('CLICK', e)
        }, 200);

        // If the time of two clicks is less than 200ms, it is considered as double clicking
        if (Date.now() - this.lastClickTime <= 200) {
            clearTimeout(this.clickTimer)
            this.clickTimer = null
            this.lastClickTime = 0
            this.observer.publish('DOUBLE-CLICK', e)
        }

        this.lastClickTime = Date.now()// Last click time
    }
}

The principle is very simple. The click event is sent after a certain delay. Compare whether the time of two clicks is less than a certain time interval. If less than, it is considered a click. Here, 200 milliseconds is selected. Of course, it can be a little smaller, but my hand speed is no longer 100 milliseconds.

Annotation function

Annotation is undoubtedly the core function of this library. As mentioned above, it is also used as a plug-in:

export default function EditPlugin(instance) {
    // Dimension logic
}

Let's take care of the function first. Click To determine each vertex of the annotation area. Double click to close the area path. You can click again to activate editing. Editing can only drag the whole or a vertex, and you can't delete or add vertices. Multiple annotation areas can exist on the same canvas at the same time, but you can only click to activate one of them for editing at a certain time.

Because multiple labels can exist on the same canvas and each label can also be edited, each label must maintain its state, so you can consider using a class to represent the label object:

export default class MarkItem {
    constructor(ctx = null, opt = {}) {
        this.pointArr = []// vertex array 
        this.isEditing = false// Edit status
        // More properties
    }
    // method...
}

Then you need to define two variables:

export default function EditPlugin(instance) {
    // List of all dimension objects
    let markItemList = []
    // Dimension objects in the current edit
    let curEditingMarkItem = null
    // Is a new dimension being created, i.e. the current dimension has not yet closed the path
    let isCreateingMark = false
}

Store all labels and the currently active label area. Next, listen to mouse events to draw. Click the event to check whether there is an active object currently. If it exists, judge whether it has been closed. If it does not exist, check whether there is a label object at the position where the mouse clicks. If it exists, activate it.

instance.on('CLICK', (e) => {
    let inPathItem = null
    // Creating a new dimension
    if (isCreateingMark) {
        // There are currently active objects with unclosed paths. Click Add vertex
        if (curEditingMarkItem) {
            curEditingMarkItem.pushPoint(x, y)// This method adds vertices to the vertex array of the current dimension instance
        } else{// If the active object does not currently exist, a new dimension instance is created
            curEditingMarkItem = createNewMarkItem()// This method is used to instantiate a new dimension object
            curEditingMarkItem.enable()// Make dimension objects editable
            curEditingMarkItem.pushPoint(x, y)
            markItemList.push(curEditingMarkItem)// Add to dimension object list
        }
    } else if (inPathItem = checkInPathItem(x, y)) {// Detect whether there is a marked area at the position where the mouse clicks, and activate it if it exists
        inPathItem.enable()
        curEditingMarkItem = inPathItem
    } else {// Otherwise, clear the current status, such as active status, etc
        reset()
    }
    render()
})

There are many new methods and properties above, which are explained in detail. The specific implementation is very simple, so I won't expand it. I'm interested in reading the source code myself. Let's focus on two of them, checkInPathItem and render.

The checkInPathItem function loops through the markItemList to detect whether a current position is within the path of the annotation area:

function checkInPathItem(x, y) {
    for (let i = markItemList.length - 1; i >= 0; i--) {
        let item = markItemList[i]
        if (item.checkInPath(x, y) || item.checkInPoints(x, y) !== -1) {
            return item
        }
    }
}

checkInPath and checkInPoints are two methods on the MarkItem prototype, which are respectively used to detect whether a position is within the path of the annotation area and within each vertex of the annotation:

export default class MarkItem {
    checkInPath(x, y) {
        this.ctx.beginPath()
        for (let i = 0; i < this.pointArr.length; i++) {
            let {x, y} = this.pointArr[i]
            if (i === 0) {
                this.ctx.moveTo(x, y)
            } else {
                this.ctx.lineTo(x, y)
            }
        }
        this.ctx.closePath()
        return this.ctx.isPointInPath(x, y)
    }
}

First, draw and close the path according to the affirmative object's current vertex array, then call the isPointInPath method in the canvas interface to determine whether the point is in the path. The isPointInPath method is only directed against the path and is valid for the current path, so if the vertex is square shape, fillRect can not be used. To draw, use rect:

export default class MarkItem {
    checkInPoints(_x, _y) {
        let index = -1
        for (let i = 0; i < this.pointArr.length; i++) {
            this.ctx.beginPath()
            let {x, y} = this.pointArr[i]
            this.ctx.rect(x - pointWidth, y - pointWidth, pointWidth * 2, pointWidth * 2)
            if (this.ctx.isPointInPath(_x, _y)) {
                index = i
                break
            }
        }
        return index
    }
}

The render method also traverses the markItemList and calls the drawing method of the MarkItem instance. The drawing logic is basically the same as that of the detection path above. However, when detecting the path, as long as the path is drawn, the drawing needs to call stroke, fill and other methods to trace and fill, which is not invisible.

Click here to create a new dimension and activate the dimension. Double click to do it. Just close the unclosed path:

instance.on('DOUBLE-CLICK', (e) => 
    if (curEditingMarkItem) {
        isCreateingMark = false
        curEditingMarkItem.closePath()
        curEditingMarkItem.disable()
        curEditingMarkItem = null
        render()
    }
})

Here, the core annotation function is completed. Next, let's look at a function to improve the experience: detecting the intersection of line segments.

Vector cross multiplication can be used to detect the intersection of line segments. For details, please refer to this article: https://www.cnblogs.com/tuyang1129/p/9390376.html.

// Check whether line segments AB and CD intersect
// a,b,c,d: {x, y}
function checkLineSegmentCross(a, b, c, d) {
    let cross = false
    // vector
    let ab = [b.x - a.x, b.y - a.y]
    let ac = [c.x - a.x, c.y - a.y]
    let ad = [d.x - a.x, d.y - a.y]
    // Vector cross multiplication, judgment points c and D are on both sides of line ab, condition 1
    let abac = ab[0] * ac[1] - ab[1] * ac[0]
    let abad = ab[0] * ad[1] - ab[1] * ad[0]

    // vector
    let dc = [c.x - d.x, c.y - d.y]
    let da = [a.x - d.x, a.y - d.y]
    let db = [b.x - d.x, b.y - d.y]
    // Vector cross multiplication, judgment points a and B are on both sides of line segment cd, condition 2
    let dcda = dc[0] * da[1] - dc[1] * da[0]
    let dcdb = dc[0] * db[1] - dc[1] * db[0]

    // If condition 1 and condition 2 are met at the same time, the line segments intersect
    if (abac * abad < 0 && dcda * dcdb < 0) {
        cross = true
    }
    return cross
}

With the above method to detect the intersection of two line segments, all you need to do is traverse the labeled vertex array to connect the line segments, and then compare them.

The method of dragging labels and vertices is also very simple. Monitor the mouse press event. Use the above method to detect whether the point is in the path to judge whether the pressed position is in the path or vertex. If yes, monitor the mouse movement event to update the overall pointArr array or the X and Y coordinates of a vertex.

Here, all the annotation functions are completed.

Plug in Example

Next, let's look at a simple picture plug-in. The picture plug-in loads pictures, and then adjusts the width and height of the canvas according to the actual width and height of the pictures. It's very simple:

export default function ImgPlugin(instance) {
    let _resolve = null
    let promise = new Promise((resolve) => {
        _resolve = resolve
    })
    
    // Load picture
    utils.loadImage(opt.img)
        .then((img) => {
            imgActWidth = image.width
            imgActHeight = image.height
            setSize()
            drawImg()
            _resolve()
        })
        .catch((e) => {
            _resolve()
        })
    
    // Modify the width and height of the canvas
    function setSize () {
        // The width and height of the container are greater than the actual width and height of the picture, and there is no need to zoom
        if (elRectInfo.width >= imgActWidth && elRectInfo.height >= imgActHeight) {
            actEditWidth = imgActWidth
            actEditHeight =imgActHeight
        } else {// The width and height of the container is smaller than the actual width and height of the picture and needs to be scaled
            let imgActRatio = imgActWidth / imgActHeight
            let elRatio = elRectInfo.width / elRectInfo.height
            if (elRatio > imgActRatio) {
                // Fixed height, adaptive width
                ratio = imgActHeight / elRectInfo.height
                actEditWidth = imgActWidth / ratio
                actEditHeight = elRectInfo.height
            } else {
                // Fixed width, adaptive height
                ratio = imgActWidth / elRectInfo.width
                actEditWidth = elRectInfo.width
                actEditHeight = imgActHeight / ratio
            }
        }
        
        canvas.width = actEditWidth
        canvas.height = actEditHeight
    }
    
    // Create a new canvas element to display the picture
    function drawImg () {
        let canvasEle = document.createElement('canvas')
        instance.el.appendChild(canvasEle)
        let ctx = canvasEle.getContext('2d')
        ctx.drawImage(image, 0, 0, actEditWidth, actEditHeight)
    }
    
    return promise
}

summary

In this paper, the plug-in development is practiced through a simple annotation function. There is no doubt that plug-in is a good extension method. For example, vue, Vue CLi, VuePress, BetterScroll, markdown it, Leaflet, etc. all separate modules and improve functions through plug-in system, but it also requires a good architecture design, The main problem I encountered in practice was that I didn't find a good way to judge whether some attributes, methods and events should be exposed, but only when I encountered them when writing plug-ins. The main problem is that if the required method can't be accessed by three parties to develop plug-ins, it's a little troublesome. Secondly, I didn't think clearly about the functional boundary of plug-ins, It is impossible to determine which functions can be realized, which need to be understood and improved in the future.

The source code has been uploaded to github: https://github.com/wanglin2/markjs.

Blog: http://lxqnsys.com/ Official account: ideal youth Laboratory

Keywords: Javascript

Added by achild on Sat, 04 Dec 2021 04:06:15 +0200