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