Alien world tailoring tools from scratch

preface

This article realizes a complete clipping tool from the perspective of a Canvas white. In the end, it is found that there is not much Canvas content involved, but more implementation ideas.

Clipping tools currently have a lot of mature libraries, in most cases also enough, but occasionally there will be some specific clipping style requirements at this time need to customize, master or necessary ~.

Start with a scratch-off

Before the implementation of cutting tools to see a scratchoff implementation, scratchoff implementation of the idea is very simple, the bottom of a picture above is a gray mask, the mouse (click) after the place will be part of the gray mask removal.

Removal with CSS is not difficult, rare is arbitrary coordinates under the coherent removal, this is not easy to achieve CSS. Here it naturally comes to mind to use Canvas:

Some pre-canvas theory knowledge:

Save and Restore are used to store the brush state and restore the brush state, because we are working with the same brush and not storing and restoring may cause bugs that are difficult to debug. FillStyle can set the color of the current brush and support transparency. FillRect can draw a rectangle, x,y,width,height. ClearRect can erase an area as a rectangle, x,y,width,height.Copy the code
  1. Base drawing:
<style>
  #canvas {
    background: url(./gouzi.jpeg);
  }
</style>
<canvas id="canvas"></canvas>
<script>
  let canvas = document.querySelector("#canvas");
  let ctx = canvas.getContext("2d");
  canvas.width = 500;
  canvas.height = 500;
</script>
Copy the code
  1. Draw a mask:
const drawModal = () = > {
  ctx.save();
  ctx.fillStyle = "Rgba (0,0,0,0.5)";
  ctx.fillRect(0.0.500.500);
  ctx.restore();
};
Copy the code

  1. When the mouse moves, we erase the corresponding block:
const clearRect = (x, y, w, h) = > {
  ctx.save();
  ctx.clearRect(x, y, w, h);
  ctx.restore();
};
canvas.addEventListener("mousemove".(e) = > {
  const { offsetX, offsetY } = e;
  clearRect(offsetX, offsetY, 30.30);
});
Copy the code

This allows for a super simple scratch-off.

Scratch-off advances

Mask, mouse erase we have implemented, but the fly in the ointment is that the erased block can only be displayed in a rectangular way, can not support arbitrary shapes.

Canvas itself does not provide other methods for erasing. An interesting (and useful) way to save the country is to use blending (mix-blending-mode in CSS also supports blending). Among all blending modes, destination-out can be found to meet our needs. In this blending mode, the existing image is retained only where there is no overlap, just an erasing effect.

It’s not difficult to write, just replace clearRect with fillRect in 3 above:

const clearRect = (x, y, w, h) = > {
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.fillStyle = "rgba(0,0,0)";
  ctx.fillRect(x, y, w, h);
  ctx.restore();
};
Copy the code

FillStyle is not important here, the new image will only leave shape, not content.

In this way, fill is used to achieve an erasing effect consistent with clear. Now, just replace fillRect with drawImage to achieve the erasing of any graph.

const clearRect = (x, y, w, h) = > {
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.drawImage(img, x, y, w, h);
  ctx.restore();
};
Copy the code

You can see that mammoth erasers are drawn, don’t ask me why I chose mammoth, because mammoth is not banned.

Implement image cropping

Cutting tool implementation ideas

Organize a wave of requirements before implementing:

  1. Draw a black and white transparent bottom.
  2. Use a Canvas to draw the image to be clipped. Note that the image may exceed the size of the Canvas and need to be scaled.
  3. Using a different Canvas to paint a grey mask and dig a hole, here using a Canvas to load the base and paint the mask is also possible, using the mixture mentioned above, I still split it into two more intuitive.
  4. Use HTML to draw clipped box borders and handle interactive events.

Black and white transparent bottom

Transparent base is usually represented by white and white grid, you can directly put an image, but this does not highlight our force grid, CSS is not difficult to achieve, use the related properties of background.

We know that background can be gradient, and can be many, background-size and background-position can also specify the state of each background individually.

First specify two of the same gradients:

background: linear-gradient(
    45deg.#ccc 25%,
    transparent 25%,
    transparent 75%.#ccc 25%
  ), linear-gradient(45deg.#ccc 25%, transparent 25%, transparent 75%.#ccc 25%);
Copy the code

The linear-gradient(45deg, # CCC 25%, Transparent 75%, # CCC 25%) gradient produces a gradient that is gray in the upper right and lower left, and transparent in the middle.

Set the background size to the desired block size, in this case I set it to 8px:

background-size: 8px 8px;
Copy the code

Because background does not repeat itself by default, you will see a grid full of triangles.

Finally, we move the x and y of the second background (or the first one, as long as it meets the expectations) by 4px. The previous triangle and the next triangle will be combined into a whole square, which is repeated to create a transparent grid.

background-position: 0 0.4px 4px;
Copy the code

Zoom and draw pictures

As mentioned above, when drawing an image, we need to scale the image to the appropriate size of the canvas. There is nothing like object-fit in canvas: Contain (include) : the width and height of the image must be smaller than the width and height of the drawing board. If any of the folders does not contain, continue to reduce until all of them do:

const shrinkImage = ({ imageWidth, imageHeight, width, height, base = 1 }) = > {
  // Return an appropriate imageWidth and imageHeight based on height and width.

  if (imageWidth / base < width && imageHeight / base < height) {
    return {
      imageWidth: imageWidth / base,
      imageHeight: imageHeight / base,
    };
  }

  return shrinkImage({
    imageWidth,
    imageHeight,
    width,
    height,
    base: base + 0.1}); };Copy the code

A simple version of tail recursion, the divisor step can increase or decrease depending on the demand.

DrawImage takes a number of parameters, so we’ll use them all:

let baseWidth = 500;
let baseHeight = 500;
let img = new Image();
let canvasOne = document.querySelector("#canvas-one");
let basePaintParams = {
  baseOffsetX: 0.baseOffsetY: 0.w: 0.h: 0}; img.src ="./gouzi.jpeg";
img.onload = () = > {
  let { imageWidth, imageHeight } = shrinkImage({
    imageWidth: img.width,
    imageHeight: img.height,
    width: baseWidth,
    height: baseHeight,
  });
  ctxOne.drawImage(
    img,
    0.0,
    img.width,
    img.height,
    (baseWidth - imageWidth) / 2,
    (baseHeight - imageHeight) / 2,
    imageWidth,
    imageHeight
  );
  basePaintParams = {
    baseOffsetX: (baseWidth - imageWidth) / 2.baseOffsetY: (baseHeight - imageHeight) / 2.w: imageWidth,
    h: imageHeight,
  };
};
Copy the code

In drawImage, the first parameter is the original image, two or three parameters identify the clipping xy of the original image, and four or five parameters represent the clipping width and height of the original image. Six or seven parameters represent xy drawn to the Canvas. In this case, the width and height of the Canvas are subtracted from the width and height of the image drawn and divided by 2 to get the middle xy. Eight and nine are the width and height drawn to the Canvas.

Here we declare a basePaintParams variable, which we will not use now, but will use later when we draw the clipping box, and initialize it to the same xy width and height as the drawn image.

MDN Reference: drawImage

Mask and hollow out

The concrete implementation of mask and hollowing can be used in scratch-off, which implements a rectangular clipping box, blending and clearRect.

The mask:

let canvasTwo = document.querySelector("#canvas-two");

const paintModal = () = > {
  ctxTwo.clearRect(0.0, baseWidth, baseHeight);
  ctxTwo.fillStyle = "Rgba (0,0,0,0.5)";
  ctxTwo.fillRect(0.0, baseWidth, baseHeight);
};

paintModal();
Copy the code

Hollowing out:

const clipModal = () = > {
  ctxTwo.save();
  ctxTwo.clearRect(
    basePaintParams.baseOffsetX,
    basePaintParams.baseOffsetY,
    basePaintParams.w,
    basePaintParams.h
  );
  ctxTwo.restore();
};

// Wait until after img onload to draw, or provide an initialization argument.
clipModal();
Copy the code

This is not difficult to achieve with HTML+CSS, there is no need to use Canvas.

Draw clipping box

Specific clipping box we use HTML to achieve, binding events these or HTML to comfortable, regular shape clipping box HTML can be very easy to write, strange shapes can also use clip-path to achieve, for the moment not to go into the Canvas of the rough water.

<style>
  .cropper-clip {
    position: absolute;
    cursor: all-scroll;
    border: 1px solid rgb(30.158.251);
  }
  .dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: #1e9efb;
    position: absolute;
  }

  .topleft {
    top: -5px;
    left: -5px;
    cursor: nwse-resize;
  }

  .topright {
    top: -5px;
    right: -5px;
    cursor: nesw-resize;
  }

  .topcenter {
    top: -5px;
    left: 50%;
    transform: translateX(-50%);
    cursor: ns-resize;
  }

  .bottomcenter {
    bottom: -5px;
    left: 50%;
    transform: translateX(-50%);
    cursor: ns-resize;
  }

  .bottomleft {
    bottom: -5px;
    left: -5px;
    cursor: nesw-resize;
  }

  .bottomright {
    bottom: -5px;
    right: -5px;
    cursor: nwse-resize;
  }

  .leftcenter {
    top: 50%;
    transform: translateY(-50%);
    left: -5px;
    cursor: ew-resize;
  }

  .rightcenter {
    top: 50%;
    transform: translateY(-50%);
    right: -5px;
    cursor: ew-resize;
  }
</style>

<div class="cropper-clip">
  <div class="dot topleft"></div>
  <div class="dot topright"></div>
  <div class="dot topcenter"></div>
  <div class="dot bottomleft"></div>
  <div class="dot bottomcenter"></div>
  <div class="dot bottomright"></div>
  <div class="dot leftcenter"></div>
  <div class="dot rightcenter"></div>
</div>
Copy the code

A set of easily writable boxes, eight points in total, distributed around the edges, the position and size of the box are given when setting the mask and hollowed out:

const drawClipDiv = () = > {
  let cropperClip = document.querySelector(".cropper-clip");
  cropperClip.style.width = `The ${Math.abs(basePaintParams.w)}px`;
  cropperClip.style.height = `The ${Math.abs(basePaintParams.h)}px`;
  cropperClip.style.left = `${basePaintParams.baseOffsetX}px`;
  cropperClip.style.top = `${basePaintParams.baseOffsetY}px`;
};
Copy the code

Math.abs is used to accommodate changing the size of the clipping box over the line, if not added (this article does not implement this).

The clipped skeleton is now fully drawn, just change the coordinates and size parameters in basePaintParams, and redraw the mask gouge and clipping box.

Cutting the movement and expansion of the box

Above we have drawn the basic skeleton, and finally we just need to make the clipping box move with the mouse.

First, our current HTML structure looks like this:

<div class="cropper">
  <div class="cropper-clip">
    <div class="dot topleft"></div>
    <div class="dot topright"></div>
    <div class="dot topcenter"></div>
    <div class="dot bottomleft"></div>
    <div class="dot bottomcenter"></div>
    <div class="dot bottomright"></div>
    <div class="dot leftcenter"></div>
    <div class="dot rightcenter"></div>
  </div>
</div>
Copy the code

Let’s register a Mousemove event with Cropper so it’s easier to process.

Clipping the box body and saving a certain point by pressing requires additional variable identification.

let dotType = "";
let dotDown = false;
let clipDown = false;

const registerEvents = () = > {
  const register = (_class) = > {
    let node = document.querySelector(`.${_class}`);
    node.addEventListener("mousedown".(e) = > {
      dotDown = true;
      dotType = _class;
    });

    node.addEventListener("mouseup".() = > {
      dotDown = false;
    });
  };

  register("topcenter");
  register("bottomcenter");
  register("leftcenter");
  register("rightcenter");
  register("bottomright");
  register("bottomleft");
  register("topright");
  register("topleft");

  let clip = document.querySelector(".cropper-clip");
  clip.addEventListener("mousedown".() = > {
    clipDown = true;
  });

  clip.addEventListener("mouseup".() = > {
    clipDown = false;
  });
};
Copy the code

The overall idea of the event of the core Mousemove starts from one side. As long as the movement strategy of up and down and left and right is determined, other points can be combined:

const cardMouseMove = (e) = > {
  if(! dotDown && ! clipDown) {return;
  }

  const { offsetX, offsetY } = e;

  const topYChange = () = > {
    if (e.target === canvasTwo) {
      if(offsetY < basePaintParams.baseOffsetY) { basePaintParams.h += basePaintParams.baseOffsetY - offsetY; basePaintParams.baseOffsetY = offsetY; }}else if(e.target === clip) { basePaintParams.h -= offsetY; basePaintParams.baseOffsetY += offsetY; }};const bottomYChange = () = > {
    if (e.target === canvasTwo) {
      if(offsetY > basePaintParams.baseOffsetY) { basePaintParams.h = offsetY - basePaintParams.baseOffsetY; }}else if(e.target === clip) { basePaintParams.h = offsetY; }};const leftXChange = () = > {
    if (e.target === canvasTwo) {
      if(offsetX < basePaintParams.baseOffsetX) { basePaintParams.w += basePaintParams.baseOffsetX - offsetX; basePaintParams.baseOffsetX = offsetX; }}else if(e.target === clip) { basePaintParams.w -= offsetX; basePaintParams.baseOffsetX += offsetX; }};const rightXChange = () = > {
    if (e.target === canvasTwo) {
      if(offsetX > basePaintParams.baseOffsetX) { basePaintParams.w = offsetX - basePaintParams.baseOffsetX; }}else if(e.target === clip) { basePaintParams.w = offsetX; }}; };Copy the code

It is important to note that the event we registered is on the parent node, so the target of the event could be the mask we drew (when zooming in) or the clipping box (when zooming out), with a different offset.

Then we just need to call different methods based on the desired change in xy of the 8 points:

const dotRunMap = {
  topcenter: () = > {
    topYChange();
  },
  bottomcenter: () = > {
    bottomYChange();
  },
  leftcenter: () = > {
    leftXChange();
  },
  rightcenter: () = > {
    rightXChange();
  },
  bottomright: () = > {
    rightXChange();
    bottomYChange();
  },
  bottomleft: () = > {
    leftXChange();
    bottomYChange();
  },
  topright: () = > {
    rightXChange();
    topYChange();
  },
  topleft: () = >{ leftXChange(); topYChange(); }};if (dotDown) {
  dotRunMap[dotType]();
}

// If it is not pressed, it is moving, there is no edge limit here, it will go out of the circle.
if(clipDown && ! dotDown) { basePaintParams.baseOffsetX = basePaintParams.baseOffsetX + e.movementX; basePaintParams.baseOffsetY = basePaintParams.baseOffsetY + e.movementY; } drawClipDiv(); paintModal(); clipModal();Copy the code

Don’t forget to redraw cropped boxes, masks and hollows.

Preview and Save

The preview needs to use another Canvas to connect the disk. Here, getImageData and putImageData are mainly used, and most of the parameters are the same. The pit to be noted is that if it is a cross-domain image, Canvas will consider it contaminated by default. In this case, you need to set the cross-domain Settings of the original image request. For more details, please refer to this article.

<canvas id="clipImage"/ >;const save = () = > {
  let bgCtx = canvasOne;

  let imageData = bgCtx.getImageData(
    basePaintParams.w < 0
      ? basePaintParams.baseOffsetX + basePaintParams.w
      : basePaintParams.baseOffsetX,
    basePaintParams.h < 0
      ? basePaintParams.baseOffsetY + basePaintParams.h
      : basePaintParams.baseOffsetY,
    Math.abs(basePaintParams.w),
    Math.abs(basePaintParams.h)
  );

  let targetCanvas = clipImage.getContext("2d");
  clipImage.width = basePaintParams.w;
  clipImage.height = basePaintParams.h;
  targetCanvas.putImageData(
    imageData,
    0.0.0.0,
    basePaintParams.w,
    basePaintParams.h
  );
};
Copy the code

It is a common practice to save the Canvas to the base64 form of dataURL and then to the Blob, which can be downloaded.

const dataURItoBlob = async (url) => await (await fetch(url)).blob();
const saveData = (function () {
  const a = document.createElement("a");
  document.body.appendChild(a);
  return function (blob, fileName) {
    let url = window.URL.createObjectURL(blob);
    a.href = url;
    a.download = fileName;
    a.click();
    window.URL.revokeObjectURL(url); }; }) ();const exportImg = async() = > {let MIME_TYPE = "image/png";
  let imgURL = clipImage.toDataURL(MIME_TYPE);
  let res = await dataURItoBlob(imgURL);
  saveData(res, "data.png");
};
Copy the code

conclusion

Such a clipping tool with complete basic functions is completed. The core clipping part of the code is not much, and the content of Canvas is not much, so it is very suitable to get familiar with a wave of Canvas. With the skeleton, other functions can be expanded along this vein.

A Vue3 version is packaged here and can be found on Github if necessary.