Native js realizes image cropping from scratch

Effect picture

                        

In my last article, I have introduced how to implement image compression. This article mainly explains the image cropping function that is implemented separately on this basis.

Click to select a file to upload a picture. When you click Crop, a crop box will appear. Moving or stretching the crop box will generate a cropped picture below. Click the drop-down box to select the compression ratio of the crop. Finally click Generate to compress the crop After downloading the picture to the local.

githup complete code address

 

HTML structure

                                            

Corresponding page map

                         

 

Cropping function

 

                           

Thinking analysis

After the user clicks to select the file, the picture is converted into base64 encoding and previewed on the page.The core of the cropping function is to implement the dashed crop box in the middle.

  • The first basic function of the cropping frame is that when the user presses the mouse on the frame, the frame can be dragged, and after the button is released, it can no longer be dragged.
  • The small yellow square in the lower right corner of the cropping box can be stretched to change the size of the box by clicking and dragging it.

The above two functions are the key points to achieve cropping. The ultimate goal is to obtain scale data through cropping even if the task is completed, that is, the left of the cropping box from the left border, the top from the upper border, and the width and height of the cropping box itself. The mission is to obtain the four data of left, top, width and height, and then we will use other means to generate a cropped picture through these four data.

 

Move the crop box

Let's first implement the first core function of the crop box.After the mouse is pressed on the frame, the frame can be dragged, and after the button is released, it cannot be dragged.

 

 

First define a constructor cropImg, and use the objects it generates to achieve cropping. At present, you only need to pay attention to this.target in the constructor, which is a div element, the same size as the picture, and the picture completely fills the div in the form of a background image. It is the dashed box extending from the picture above. This.target is equivalent to the original picture. For the convenience of description, we will call this.target the picture box.

function cropImg(options) {
  this.target = document.getElementById(options.target) || document;
  this.width = 0;
  this.height = 0;
  this.container_width = 0;
  this.container_height = 0;
  this.mouse_index = 0;
  this.callback = options.callback || function () {};
  this.fun_list = []; //所有绑定的事件存储起来,插件销毁时解绑
  this.init();
}

In this.init() function, the following logic of rendering the crop box will be executed.

Before doing the moving function, you need to render the cropping box on the page, otherwise you can't talk about how to move it without the box. The cropping box is also a div, which is rendered inside the picture box.

  • Set the aspect ratio of the cropping frame to be consistent with the aspect ratio of the picture frame in advance, and the cropping frame is absolutely positioned relative to the picture frame
  • The width and height of the initial rendering cropping frame are set to 1/3 of the picture frame (scale data can be set freely)
  • The initial rendering of the crop box is in the center of the picture box

The following code is to do the above three things, create a div element container as the crop frame object, calculate the width and height of the picture frame to get the width and height of the crop frame, and set its left and top so that it is in the picture frame Right in the center.

cropImg.prototype.renderBox = function () {
  const width = (this.container_width = parseInt( //算出图片框的宽
    getComputedStyle(this.target).width
  ));
  const height = (this.container_height = parseInt( //算出图片框的高
    getComputedStyle(this.target).height
  ));
  this.radio = width / height; //算出宽高比例
  this.width = parseInt(width / 3); //得到裁剪框的宽
  this.height = parseInt(height / 3); //得到裁剪框的高
  const container = document.createElement('DIV'); //创建裁剪框
  container.style.width = `${this.width}px`;
  container.style.height = `${this.height}px`;
  container.style.left = `${this.container_width/2 - this.width/2}px`; //初次加载裁剪框放到正中间的位置 
  container.style.top = `${this.container_height/2 - this.height/2}px`;
};

Now you only need to plug the crop box container into the picture box this.target and you can see it on the page. Before that, now the mobile function of the crop box is ready. How to develop the mobile function? It is nothing more than the crop box. div does event binding.

  • Bind the mouse down event to the crop box (mousedown). In this event, a state must be defined to indicate whether the mouse has been pressed. Because the crop box can only be dragged when the mouse is pressed. The event object through the event function event records the starting position of the current mouse press start_x, start_y
  • Bind the mouse movement event to the crop box (mousemove), each time the event is triggered, it is necessary to verify whether the mouse is still in the pressed state, otherwise it refuses to move. When the event is triggered, the current mouse position pageX can be obtained by obtaining the event object event. pageY. Let pageX-start_x get the distance of the mouse's horizontal movement, and let pageY-start_y get the distance of the mouse's vertical. Assign the distance of the mouse's horizontal and vertical movement to the left and top of the crop box to realize how much the mouse moves. The crop box will also follow How far to move in the corresponding direction. After each movement, start_x and start_y need to be updated to the current mouse position pageX and pageY to prepare for the next move event.
  • According to habit, the mouse down event should also be bound to the crop box. Its function is to change to the mouse up state. As long as the mouse is up, the movement event cannot be triggered again, unless it is pressed again. Mouse movement. But in the actual development, a phenomenon was found. If a mouseup event is bound to a div, mouseup will be triggered only when the mouse is released inside the div. Once the mouse is moved outside the div and released, the mouseup event will be completely released. Invalid. In order to make the crop box move more smoothly, finally decided to bind the mouse up event to the global document.

The key to realize the movement function is the writing of the above three mouse events mousedown, mousemove and mouseup. In the current project, not only the crop box needs to be bound to these three events, but the small square in the lower right corner of the crop box also needs to be bound to the mouse event. To stretch the cropping frame, as shown below:

                    

Since both stretching and moving need to bind mouse events, we can extract the logic of binding events separately for other functions to call. Now we need to bind mousedown, mousemove and mouseup events to the crop box, how to call it?

 this.bindMouseEvent({
    mousedown: {
      element: container,//container是裁剪框的dom对象
    },
    mousemove: {
      element: container,
      callback(e, start_x, start_y) {
          console.log("每次触发移动事件后的回调函数");   
      },
    },
  });

The bindMouseEvent function needs to pass a parameter object, the key is the name of the event to be bound, the element is the dom element that needs to be bound to the event, and the callback is the callback function after each event is triggered.

As mentioned above, the crop box is ready to bind two events mousedown and mousemove, and mousemove also defines a callback function callback. How is the logic of bindMouseEvent written?

The following piece of code is the core of the clipping function. MouseUpHandler will look at it later and pay attention to the logic implemented in the for loop. Params is the parameter object passed above {mousedown: {element: container}, mousemove: {element: container,callback() ())). Directly perform a for in loop on params to obtain the element and callback of each object. The purpose is to call addEventListener(key,fn) on the element. The key is the event type, which is easy to get. And fn It is the function that needs to be bound, and fn is obtained by calling the this.strategyEvent function.

/**
 * 对dom元素绑定鼠标点击弹起和移动事件
 */
cropImg.prototype.bindMouseEvent = function (params) {
  this.mouseUpHandler(); //处理mouseup事件

  this.mouse_index++; //每当需要绑定一次鼠标事件,mouse_index自增1,作为唯一的id标识

  for (let key in params) {
    const value = params[key]; // 得到key和value
    let { element, callback } = value;
    const defaultFn = this.strategyEvent(key, this.mouse_index); //获取默认运行函数
    if (!defaultFn) {
      //如果发现params的参数配置里面的key没有和在策略函数里定义的默认函数匹配上,那么判定当前对应的key-value是无效的
      continue;
    }
    element = Array.isArray(element) ? element : [element]; //不是数组也转化成数组
    element.forEach((ele) => {
      const fn = (e) => {
        //开始绑定事件
        defaultFn.call(this, e, callback); //某些默认策略函数需要callback参数,所以params.callback也作为参数传入
      };
      ele.addEventListener(key, fn);
    });
  }
};

Why does the event binding function fn need to be obtained through the this.strategyEvent function? It's okay if you don't do this, then you need to write a lot of if else code, as shown below. The code written in this way is not elegant, and we call this above. StrategyEvent can directly get the function to be bound according to the key defaultFn.this.strategyEvent is equivalent to a function factory, and you pass it a key and it returns you a processing function. Here the strategy mode is used to optimize the if else structure.

if(key === "mouseup"){
  
 ele.addEventListener(key, function(){
   
 });

}else if(key === "mousemove"){
 
 ele.addEventListener(key, function(){
   
 }); 

}else if(key === "mousedown"){

 ele.addEventListener(key, function(){
   
 });

}

What bindMouseEvent does is very pure, it is to bind different event functions to different element elements by calling addEventListener, and where is the specific content of the event function? This.strategyEvent defines the logic of different event functions.

The key in the strategyEvent function below is the type of event passed in, such as "mousedown" or "mousemove". idx is the globally unique id identifier, and it increments every time the bindMouseEvent function is called. 1. According to the current clipping frame and pull as described above The stretch block needs to be bound to the mouse event, then the corresponding idx when the crop box is bound to the event is equal to 1, and the corresponding idx of the stretch block is 2.

  • There are only two events done by mousedown. The first is to define a state that reflects whether the mouse is pressed or up. The second is to record the position of the mouse when it is pressed.
  • In the mousemove event, it uses the value of if (!this[`mouse_${idx}`]) to detect whether the mouse has been pressed, and if it has been bounced, it will not do any operation. Then define a timer to throttle (section The flow is mainly for performance considerations, and does not affect the implementation of the function), the callback function is called in the timer. This callback function is the parameter passed in when calling, as shown in the figure below, the starting position start_x is passed in when calling callback , start_y and time object e. Renew the position of start_x and start_y after executing the callback.
  •  

                                      

/**
 * 定义一些策略函数
 */
cropImg.prototype.strategyEvent = function (key, idx) {
  function mousedown(e) {
    //鼠标按下时的默认操作
    e.stopPropagation();
    this[`mouse_${idx}`] = true; //检测鼠标是否处于按下的状态
    this[`start_x${idx}`] = e.pageX;
    this[`start_y${idx}`] = e.pageY;
  }

  function mousemove(e, callback) {
    //鼠标移动时的默认操作
    e.stopPropagation();
    e.preventDefault();
    if (!this[`mouse_${idx}`]) {
      return false;
    }
    if (this[`timer${idx}`]) {
      return false;
    }
    this[`timer${idx}`] = setTimeout(() => {
      callback.call(this, e, this[`start_x${idx}`], this[`start_y${idx}`]);
      this[`start_x${idx}`] = e.pageX;
      this[`start_y${idx}`] = e.pageY;
      clearTimeout(this[`timer${idx}`]);
      this[`timer${idx}`] = null;
    }, 20);
  }

  const funList = { mousedown, mousemove };

  return funList[key];
};

The callback function is called every time the mousemove function is triggered. The calback function receives the event object e and the initial coordinate positions start_x and start_y. The event object e can obtain the current mouse position e.pageX and e.pageY. Let e.pageX- start_x can get the distance of the mouse's horizontal movement, e.pageY-start_y get the distance of the mouse's vertical movement. Now that we can get the distance of the mouse's movement, we can make the crop box move in the callback.

When calling, the logic of the callback is completed. Each time the mousemove event of the crop box is triggered, the following callback function will be called. x is the distance of the mouse's lateral movement, and y is the distance of the mouse's longitudinal movement. x and y plus cropping The left and top of the box are re-assigned to the crop box, so the position of the crop box will slide with the movement of the mouse.

 this.bindMouseEvent({
    mousedown: {
      element: container,
    },
    mousemove: {
      element: container,
      callback(e, start_x, start_y) {
        const x = e.pageX - start_x;
        const y = e.pageY - start_y;
        let top = parseInt(getComputedStyle(container).top);
        let left = parseInt(getComputedStyle(container).left);
        top += y;
        left += x;
        container.style.top = `${top}px`;
        container.style.left = `${left}px`;
      },
    },
  });

Only mousemove and mousedown events are written above, and the mouseup event is handled separately. Its logic is very simple. It monitors the mouse bounce globally and changes the state of the mouse once it is triggered.


/**
 * mouseup处理函数
 */
cropImg.prototype.mouseUpHandler= function(){
 if (this.mouse_index > 0) {  //已经绑定过mouseup事件了,mouseup事件绑定一次即可
    return false;
  }
  document.addEventListener('mouseup', ()=>{
     Array.from(Array(this.mouse_index)).forEach((value, idx) => {
       this[`mouse_${idx + 1}`] = false;
     });
  });  
}

 

Stretch the crop box

 

                  

The small square marked by the red line in the figure above can be stretched to change the size of the cropping frame. Its realization is still to render the small square first, and then bind the mousedown event to the small square, but do not bind the mousemove event to the small square, because The area of ​​the small square is too small, and the mouse can easily slide out to affect the stretching effect. Therefore, it is best to bind the mousemove event to the crop frame and the picture frame at the same time, and the crop frame is stretched proportionally when the function is triggered.

The code is as follows. Create a div element symbol as the dom object of the small square, and then bind the mouse event to the dom object

  • Bind the mousedown event to the small square, this.mask is the dom object of the crop box.
  • Bind the mousemove event to the picture frame and the crop frame, call callback.calback function after each event is triggered to calculate the distance x of the mouse lateral movement through e.pageX-start_x. The width of the crop frame + x is equal to the width of the crop frame after stretching Width, because it needs to be stretched in the same proportion, the width and the aspect ratio of the cropping frame can be calculated using this.radio. The height after stretching can be calculated. Re-assign width and height to the cropping frame, and the size of the dom element will change.
  • Put the small square symbol in the element of the crop box this.mask and render it on the page.
/**
 * 渲染右下角的拉升框
 */
cropImg.prototype.renderSymbol = function () {
  const symbol = document.createElement('DIV');
  symbol.setAttribute('class', 'symbol');
  this.bindMouseEvent({
    mousedown: {
      element: symbol,
    },
    mousemove: {
      element: [this.target, this.mask],
      callback(e, start_x) {
        const x = e.pageX - start_x;
        const width = parseInt(getComputedStyle(this.mask).width) + x;
        const height = parseInt((width * 1) / this.radio);
        this.mask.style.width = `${width}px`;
        this.mask.style.height = `${height}px`;
        this.width = width;
        this.height = height;
      },
    },
  });
  this.symbol = symbol;
  this.mask.appendChild(symbol);
};

 

External call crop plugin

Now call the cropImg plug-in just developed externally, "box" is the id of the picture frame, which is the this.target defined in the plug-in. We expect the callback function to return the scale data of the current crop frame, that is, the width of the crop frame Height and left from the left border and top from the upper border. Container_height and container_width are the width and height of the picture box.

new cropImg({
      target: 'box',
      callback({left,top,width,height,container_height,container_width,}) {
       
      },
    });

Add this.callback property to the constructor for external calls

function cropImg(options) {
  this.target = document.getElementById(options.target) || document;
  this.width = 0;
  this.height = 0;
  this.container_width = 0;
  this.container_height = 0;
  this.mouse_index = 0;
  this.callback = options.callback || function () {};
  this.init();
}

At present, the plugin does not have the function of returning scale data, we can put this.callback in mouseup to execute. Every time the mouse pops up, the this.callback function is executed and the related scale data is returned.

/**
 * mouseup处理函数
 */
cropImg.prototype.mouseUpHandler= function(){

 if (this.mouse_index > 0) {  //已经绑定过mouseup事件了
    return false;
  }

  document.addEventListener('mouseup', ()=>{

     Array.from(Array(this.mouse_index)).forEach((value, idx) => {
       this[`mouse_${idx + 1}`] = false;
     });
     
      const { top, left } = this.mask.style; //this.mask是裁剪框的dom对象

      this.callback({ //将相关比例数据返回
       width: this.width, //裁剪框的宽
       height: this.height, //裁剪框的高
       top: parseInt(top),
       left: parseInt(left),
       container_height: this.container_height,//图片框的高
       container_width: this.container_width, //图片框的宽
    });   

  });  
}

Now every time the mouse is dragged or stretched, it will execute the callback function and give the scale data of the crop box at this time. Next, we need to generate a cropped picture based on these scale data.

  • Create a new copy canvas in the memory as canvas_bak, img is the original image uploaded and previewed, and render the img onto the copy canvas according to the width and height of the picture frame.
  • Then recreate a new canvas, so that its width and height are equal to the width and height returned by the callback (that is, the width and height of the cropping frame). Then the most critical step code ctx.drawImage(canvas_bak,left,top,width,height,0,0 ,width,height) to achieve cropping.
  • ctx.drawImage passes in 9 parameters. Its meaning is to take the value of the starting point x from the left left as the starting point on the copy canvas, and the value from the upper top as the starting point y. Take x, y as the starting point coordinates to intercept a piece A rectangle whose width is width and height is height. This is the function of the first 5 parameters. The intercepted rectangle is drawn on the new canvas, and the new canvas is completely filled with the coordinates of 0,0 as the starting point. The new canvas is displayed after the interception Picture of
  • Finally, the new canvas compression is converted to base64 encoding and then displayed on the page. This is the cropped and compressed picture.
new cropImg({
      target: 'box',
      callback({left,top,width,height,container_height,container_width,}) {

        const canvas_bak = document.createElement('CANVAS');
        const ctx_bak = canvas_bak.getContext('2d');
        canvas_bak.width = container_width;
        canvas_bak.height = container_height;
        ctx_bak.drawImage(img, 0, 0, container_width, container_height);

        const canvas = document.createElement('CANVAS');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#fff';
        ctx.fillRect(0, 0, width, height);
        ctx.drawImage(canvas_bak,left,top,width,height,
        0,0,width,height
        );

        const value = Number(document.getElementById('sel').value);
        const code = canvas.toDataURL('image/jpeg', value);
        const image = new Image();
        image.src = code;

        image.onload = () => {
          const des = document.getElementById('production');
          des.innerHTML = '';
          des.appendChild(image);
          compress_img = image;
        };
      },
    });

 

Generate picture

 Compress_img is the image object after cropping and compression. When the user clicks to generate, the generate function will be triggered. It first creates an A tag, assigns the base64 encoding of the compressed image to the href attribute, and adds a value to the download attribute, which corresponds to Is the name of the picture after downloading. Finally, click on the A tag to download the picture to the local.

  /**
   * 下载图片
   * @param {*}
   */
  function generate() {
    if (!compress_img) {
      return false;
    }
    const a = document.createElement('A');
    a.href = compress_img.src;
    a.download = 'download';
    a.click();
  }

 

Guess you like

Origin blog.csdn.net/brokenkay/article/details/107945227