preface

When customers encounter problems in the process of using our products and need to give us feedback, it is difficult for us to understand the meaning of customers if they describe the problems in the form of pure words. If we can add screenshots of the problems, we can clearly know the problems of customers.

So, we need to implement a custom for our product screenshot function, the user after the “capture” button box to choose any area, then in the box to choose the area of circle, draw the arrow, Mosaic, line, type, such as operation, after operation the user can choose save frame the content of the selected area to the local or sent directly to us.

Smart developers may have guessed that this is the screenshots function of QQ/ wechat, and my open source project just achieved the screenshots function. Before doing it, I found a lot of information, but did not find such a thing existed on the Web side, so I decided to refer to the screenshots of QQ and make it into a plug-in for everyone to use.

This article will share with you I do this “custom screen capture function” when the implementation of ideas and process, welcome you interested in the developers to read this article.

Run the result video: the realization of the Web side custom screen

Writing in the front

This plug-in is written using Vue3’s compositionAPI. For those who are not familiar with it, please refer to my other article: Using Vue3’s compositionAPI to optimize code volume

Implementation approach

Let’s first look at the QQ screenshot process, and then analyze how it is implemented.

Screen capture process analysis

So let’s start by analyzing how screenshots work.

  • After clicking the Screenshot button, we will notice that all the dynamic effects on the page are still, as shown below.

  • Then, we hold down the left mouse button and drag. A black mask appears on the screen, and the drag area of the mouse appears hollow out, as shown below.

  • After the dragging is complete, the toolbar will appear below the box selection area, which contains box selection, circle selection, arrow, line, brush and other tools, as shown in the picture below.

  • Click any icon in the toolbar, and the brush selection area will appear, where you can select the brush size and color as shown below.

  • We then drag and drop within the selected area of the box to draw the corresponding graph, as shown below.

  • Finally, click the download icon in the screenshot toolbar to save the picture to the local, or click the check mark and the picture will be automatically pasted into the chat input box, as shown below.

Screenshot implementation idea

Through the above screenshot process, we get the following implementation idea:

  • Gets the contents of the currently visible area and stores it
  • Draws a mask for the entire CNAVas canvas
  • Drag and drop the obtained content to draw a hollowed-out selection
  • Select the tool in the screenshot toolbar and select information such as brush size
  • Drag and drop within the selection to draw the corresponding figure
  • Converts the contents of the selection to an image

The implementation process

We have analyzed the implementation ideas, and then we will implement the above ideas one by one.

Gets the contents of the current viewable area

After clicking the screenshot button, we need to obtain the content of the entire visual area, and all subsequent operations are carried out on the obtained content. On the Web side, we can use Canvas to achieve these operations.

So, we need to convert the body area to canvas first, which is a bit complicated and a lot of work to do from scratch.

Fortunately, there is an open source library called HTML2Canvas that can convert the dom to canvas. We will use this library to implement our conversion.

Next, let’s look at the implementation process:

Create a new file named screen-short-vue to host our entire screenshot component.

  • First we need a Canvas container to display the transformed viewable content
<template> <teleport to="body"> <! < Canvas ID ="screenShotContainer" :width="screenShortWidth" :height="screenShortHeight" ref="screenShortController" ></canvas> </teleport> </template>Copy the code

Only part of the code is shown here. For the full code, go to screen-short-.vue

  • When the component is mounted, the html2Canvas method is called to convert the content in the body to canvas and store it.
import html2canvas from "html2canvas";
import InitData from "@/module/main-entrance/InitData";

export default class EventMonitoring {
  // The response data of the current instance
  private readonly data: InitData;
  // Screenshot the area canvas container
  private screenShortController: Ref<HTMLCanvasElement | null>;
  // The container where the screenshot is stored
  private screenShortImageController: HTMLCanvasElement | undefined;
  
  constructor(props: Record<string.any>, context: SetupContext<any>) {
    // Instantiate the response data
    this.data = new InitData();
    // Get the canvas container in the screenshot area
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() = > {
      // Set canvas width and height
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas= > {
        // If the dom of the screenshot is null, exit
        if (this.screenShortController.value == null) return;
        
        // Store the content captured by html2Canvas
        this.screenShortImageController = canvas; }}})})Copy the code

Only part of the code is shown here; for the full code, go to EventMonitoring

Draws a mask for the canvas

Once we have the converted DOM, we need to draw a black mask with an opacity of 0.6 to inform the user that you are in the screenshot area selection.

The specific implementation process is as follows:

  • Create the DrawMasking. Ts file where the drawing logic for the mask layer is implemented. The code is as follows.
/** * Draw mask *@param Context needs to be drawn to canvas */
export function drawMasking(context: CanvasRenderingContext2D) {
  // Clear the canvas
  context.clearRect(0.0.window.innerWidth, window.innerHeight);
  // Draw the mask
  context.save();
  context.fillStyle = "rgba(0, 0, 0, .6)";
  context.fillRect(0.0.window.innerWidth, window.innerHeight);
  // The drawing is finished
  context.restore();
}

Copy the code

The ⚠️ comments are detailed, so if you don’t understand the API, go to: clearRect, Save, fillStyle, fillRect, restore

  • inhtml2canvasThe draw mask function is called in the function callback
html2canvas(document.body, {}).then(canvas= > {
  // Get the screenshot area to draw the canvas container canvas
  const context = this.screenShortController.value? .getContext("2d");
  if (context == null) return;
  // Draw the mask
  drawMasking(context);
})
Copy the code

Draw hollowed-out selection

We drag and drop in black layer, the need to get the mouse by pressing the starting point coordinates and the coordinates of the mouse movement, according to the coordinates of the starting point coordinates and mobile, we can get an area, at this time we will cover layer chiseling the area, to get to the canvas map to cover layer beneath the image content, so that we can achieve the effect of hollow out district.

Arrange the above discourse, the idea is as follows:

  • Listen for mouse press, move, and lift events
  • Gets the coordinates of mouse down and moving
  • Chisel the mask according to the obtained coordinates
  • Draws the obtained Canvas image content under the mask
  • To achieve hollow selection drag and zoom

The effects are as follows:

The specific code is as follows:

export default class EventMonitoring {
   // The response data of the current instance
  private readonly data: InitData;
  
  // Screenshot the area canvas container
  private screenShortController: Ref<HTMLCanvasElement | null>;
  // The container where the screenshot is stored
  private screenShortImageController: HTMLCanvasElement | undefined;
  // Screenshot the area canvas
  private screenShortCanvas: CanvasRenderingContext2D | undefined;
  
  // Figure position parameter
  private drawGraphPosition: positionInfoType = {
    startX: 0.startY: 0.width: 0.height: 0
  };
  // Temporary graphics position parameter
  private tempGraphPosition: positionInfoType = {
    startX: 0.startY: 0.width: 0.height: 0
  };

  // Crop the frame node coordinate event
  private cutOutBoxBorderArr: Array<cutOutBoxBorder> = [];
  
  // Trim the vertex border diameter of the box
  private borderSize = 10;
  // The border node of the current operation
  private borderOption: number | null = null;
  
  // Click the mouse coordinates of the cropping box
  private movePosition: movePositionType = {
    moveStartX: 0.moveStartY: 0
  };

  // Trim the box trim state
  private draggingTrim = false;
  // Crop the box drag state
  private dragging = false;
  // Mouse click state
  private clickFlag = false;
  
  constructor(props: Record<string.any>, context: SetupContext<any>) {
     // Instantiate the response data
    this.data = new InitData();
    
    // Get the canvas container in the screenshot area
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() = > {
      // Set canvas width and height
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas= > {
        // If the dom of the screenshot is null, exit
        if (this.screenShortController.value == null) return;

        // Store the content captured by html2Canvas
        this.screenShortImageController = canvas;
        // Get the screenshot area to draw the canvas container canvas
        const context = this.screenShortController.value? .getContext("2d");
        if (context == null) return;

        // Assign the screenshot area to canvas
        this.screenShortCanvas = context;
        // Draw the mask
        drawMasking(context);

        // Add listener
        this.screenShortController.value? .addEventListener("mousedown".this.mouseDownEvent
        );
        this.screenShortController.value? .addEventListener("mousemove".this.mouseMoveEvent
        );
        this.screenShortController.value? .addEventListener("mouseup".this.mouseUpEvent ); })})}// Mouse down event
  private mouseDownEvent = (event: MouseEvent) = > {
    this.dragging = true;
    this.clickFlag = true;
    
    const mouseX = nonNegativeData(event.offsetX);
    const mouseY = nonNegativeData(event.offsetY);
    
    // If the operation is clipping box
    if (this.borderOption) {
      // Set the drag state
      this.draggingTrim = true;
      // Record the starting point of the move
      this.movePosition.moveStartX = mouseX;
      this.movePosition.moveStartY = mouseY;
    } else {
      // Draw a clipping box to record the current mouse start coordinates
      this.drawGraphPosition.startX = mouseX;
      this.drawGraphPosition.startY = mouseY; }}// Mouse movement event
  private mouseMoveEvent = (event: MouseEvent) = > {
    this.clickFlag = false;
    
    // Get the clipping box location information
    const { startX, startY, width, height } = this.drawGraphPosition;
    // Get the current mouse coordinates
    const currentX = nonNegativeData(event.offsetX);
    const currentY = nonNegativeData(event.offsetY);
    // Cut the temporary width and height of the frame
    const tempWidth = currentX - startX;
    const tempHeight = currentY - startY;
    
    // Perform the clipping function
    this.operatingCutOutBox(
      currentX,
      currentY,
      startX,
      startY,
      width,
      height,
      this.screenShortCanvas
    );
    // Return if the mouse is not clicked or if the current operation is cropping box
    if (!this.dragging || this.draggingTrim) return;
    // Draw the clipping box
    this.tempGraphPosition = drawCutOutBox(
      startX,
      startY,
      tempWidth,
      tempHeight,
      this.screenShortCanvas,
      this.borderSize,
      this.screenShortController.value as HTMLCanvasElement,
      this.screenShortImageController as HTMLCanvasElement
    ) as drawCutOutBoxReturnType;
  }
  
    // Mouse raise event
  private mouseUpEvent = () = > {
    // The drawing is finished
    this.dragging = false;
    this.draggingTrim = false;
    
    // Save the position information of the drawn graph
    this.drawGraphPosition = this.tempGraphPosition;
    
    // Save the clipping position if the toolbar is not clicked
    if (!this.data.getToolClickStatus().value) {
      const { startX, startY, width, height } = this.drawGraphPosition;
      this.data.setCutOutBoxPosition(startX, startY, width, height);
    }
    // Save the border node information
    this.cutOutBoxBorderArr = saveBorderArrInfo(
      this.borderSize,
      this.drawGraphPosition ); }}Copy the code

⚠️ There are many codes to draw hole-out selection. Here only show the related codes of the three mouse event listeners. For the complete code, go to EventMonitoring

  • The code to draw the clipping box is as follows
/** * Draw clipping box *@param MouseX x axis coordinates *@param MouseY y coordinate *@param Width Width of the clipping box *@param Height Height of clipping box *@param Context is the canvas that needs to be drawn@param BorderSize Border node diameter *@param Controller Canvas container * to operate on@param ImageController Image canvas container *@private* /
export function drawCutOutBox(
  mouseX: number,
  mouseY: number,
  width: number,
  height: number,
  context: CanvasRenderingContext2D,
  borderSize: number,
  controller: HTMLCanvasElement,
  imageController: HTMLCanvasElement
) {
  // Get the canvas width and height
  constcanvasWidth = controller? .width;constcanvasHeight = controller? .height;// Return if the canvas and image do not exist
  if(! canvasWidth || ! canvasHeight || ! imageController || ! controller)return;

  // Clear the canvas
  context.clearRect(0.0, canvasWidth, canvasHeight);

  // Draw the mask
  context.save();
  context.fillStyle = "rgba(0, 0, 0, .6)";
  context.fillRect(0.0, canvasWidth, canvasHeight);
  // Cut the cover
  context.globalCompositeOperation = "source-atop";
  // Crop the selection box
  context.clearRect(mouseX, mouseY, width, height);
  // Draw 8 border pixels and save the coordinate information and event parameters
  context.globalCompositeOperation = "source-over";
  context.fillStyle = "#2CABFF";
  // Pixel size
  const size = borderSize;
  // Draw the pixels
  context.fillRect(mouseX - size / 2, mouseY - size / 2, size, size);
  context.fillRect(
    mouseX - size / 2 + width / 2,
    mouseY - size / 2,
    size,
    size
  );
  context.fillRect(mouseX - size / 2 + width, mouseY - size / 2, size, size);
  context.fillRect(
    mouseX - size / 2,
    mouseY - size / 2 + height / 2,
    size,
    size
  );
  context.fillRect(
    mouseX - size / 2 + width,
    mouseY - size / 2 + height / 2,
    size,
    size
  );
  context.fillRect(mouseX - size / 2, mouseY - size / 2 + height, size, size);
  context.fillRect(
    mouseX - size / 2 + width / 2,
    mouseY - size / 2 + height,
    size,
    size
  );
  context.fillRect(
    mouseX - size / 2 + width,
    mouseY - size / 2 + height,
    size,
    size
  );
  // The drawing is finished
  context.restore();
  // Use drawImage to draw the image below the mask
  context.save();
  context.globalCompositeOperation = "destination-over";
  context.drawImage(
    imageController,
    0.0, controller? .width, controller? .height ); context.restore();// Return the temporary location information of the clipping box
  return {
    startX: mouseX,
    startY: mouseY,
    width: width,
    height: height
  };
}

Copy the code

⚠️ Again, the comments are very detailed. In addition to the canvas API described before, the above code uses the following new apis: globalCompositeOperation and drawImage

Implement the screenshot toolbar

After we realize the function of hollowing out the selection, the next thing to do is to select circles, boxes and lines in the selection. In the screenshot of QQ, these operations are located in the screenshot toolbar, so we need to make the screenshot toolbar to interact with canvas.

In terms of the layout of the toolbar in the screenshot, at first MY idea was to draw these tools directly on the canvas, which should be easier to interact with, but after looking at the RELATED API, I found that it was a bit cumbersome and complicated the problem.

After thinking about it for a while, I realized that this piece still needs to use div for layout. After drawing the clipping box, calculate the position of the toolbar in the screenshot according to the position information of the clipping box, and then change its position.

The toolbar interacts with the Canvas by binding a click event to EventMonitoring. Ts, obtaining the current click, and specifying the corresponding graph drawing function.

The effects are as follows:

The specific implementation process is as follows:

  • inscreen-short.vueCreate a screenshot toolbar div and style it
<template>
  <teleport to="body">
       <! -- Toolbar -->
    <div
      id="toolPanel"
      v-show="toolStatus"
      :style="{ left: toolLeft + 'px', top: toolTop + 'px' }"
      ref="toolController"
    >
      <div
        v-for="item in toolbar"
        :key="item.id"
        :class="`item-panel ${item.title} `"
        @click="toolClickEvent(item.title, item.id, $event)"
      ></div>
      <! -- Undo part is handled separately -->
      <div
        v-if="undoStatus"
        class="item-panel undo"
        @click="toolClickEvent('undo', 9, $event)"
      ></div>
      <div v-else class="item-panel undo-disabled"></div>
      <! -- Closing and validation are handled separately -->
      <div
        class="item-panel close"
        @click="toolClickEvent('close', 10, $event)"
      ></div>
      <div
        class="item-panel confirm"
        @click="toolClickEvent('confirm', 11, $event)"
      ></div>
    </div>
  </teleport>
</template>

<script lang="ts">
import eventMonitoring from "@/module/main-entrance/EventMonitoring";
import toolbar from "@/module/config/Toolbar.ts";

export default {
  name: "screen-short".setup(props: Record<string, any>, context: SetupContext<any>) {
    const event = new eventMonitoring(props, context as SetupContext<any>);
    const toolClickEvent = event.toolClickEvent;
    return {
      toolClickEvent,
      toolbar
    }
  }
}
</script>
Copy the code

⚠️ The above code shows only part of the component code. For the complete code, go to screen-short-. vue and screen-short-

Screenshot tool entry click style processing

Each item in the screenshot toolbar has three states: Normal, mouse over, and click. What I did here was to write all the states in THE CSS, using different class names to display different styles.

Part of the toolbar click status CSS is as follows:

.square-active {
  background-image: url("~@/assets/img/square-click.png");
}

.round-active {
  background-image: url("~@/assets/img/round-click.png");
}

.right-top-active {
  background-image: url("~@/assets/img/right-top-click.png");
}
Copy the code

At first when I want to v – for rendering, define a variable, click change the state of the variable, displaying each click entries corresponding click style, but I found a problem, when do I click the class name is dynamic, and didn’t send this form to get, but I had to choose the form of dom manipulation, $event = $event = $event = $event; $event = $event; $event = $event;

The implementation code is as follows:

  • Dom structure
<div
    v-for="item in toolbar"
    :key="item.id"
    :class="`item-panel ${item.title} `"
    @click="toolClickEvent(item.title, item.id, $event)"
></div>
Copy the code
  • Toolbar click events
  /** * Clipping toolbar click event *@param toolName
   * @param index
   * @param mouseEvent* /
  public toolClickEvent = (
    toolName: string,
    index: number,
    mouseEvent: MouseEvent
  ) = > {
    // Add the selected class name for the currently clicked item
    setSelectedClassName(mouseEvent, index, false);
  }
Copy the code
  • Adds the selected class for the currently clicked item and removes the selected class for its sibling elements
import { getSelectedClassName } from "@/module/common-methords/GetSelectedCalssName";
import { getBrushSelectedName } from "@/module/common-methords/GetBrushSelectedName";

/** * Adds the selected class to the current clicked item and removes the selected class * from its sibling@param MouseEvent The element * to operate on@param Index Indicates the current click *@param IsOption is a brush option */
export function setSelectedClassName(
  mouseEvent: any,
  index: number,
  isOption: boolean
) {
  // Get the class name when the currently clicked item is selected
  let className = getSelectedClassName(index);
  if (isOption) {
    // Get the corresponding class when the brush option is selected
    className = getBrushSelectedName(index);
  }
  // Get all the child elements under div
  const nodes = mouseEvent.path[1].children;
  for (let i = 0; i < nodes.length; i++) {
    const item = nodes[i];
    // Remove the selected class from the toolbar if it already exists
    if (item.className.includes("active")) {
      item.classList.remove(item.classList[2]); }}// Adds the selected class to the currently clicked item
  mouseEvent.target.className += "" + className;
}

Copy the code
  • Gets the class name when the screenshot toolbar is clicked
export function getSelectedClassName(index: number) {
  let className = "";
  switch (index) {
    case 1:
      className = "square-active";
      break;
    case 2:
      className = "round-active";
      break;
    case 3:
      className = "right-top-active";
      break;
    case 4:
      className = "brush-active";
      break;
    case 5:
      className = "mosaicPen-active";
      break;
    case 6:
      className = "text-active";
  }
  return className;
}

Copy the code
  • Gets the class name when the brush selection is clicked
/** * get the class name of the selected brush option *@param itemName* /
export function getBrushSelectedName(itemName: number) {
  let className = "";
  switch (itemName) {
    case 1:
      className = "brush-small-active";
      break;
    case 2:
      className = "brush-medium-active";
      break;
    case 3:
      className = "brush-big-active";
      break;
  }
  return className;
}

Copy the code

Implement each option in the toolbar

Next, let’s look at the implementation of each of the options in the toolbar.

The drawing of each graph in the toolbar requires the cooperation of the three events of mouse pressing, moving and lifting. In order to prevent repeated drawing of the graph when the mouse is moving, we adopt the “history” mode to solve this problem. Let’s first look at the scene of repeated drawing, as shown below:

Next, let’s look at how to use history to solve this problem.

  • First, we need to define an array variable calledhistory.
private history: Array<Record<string.any> > = [];Copy the code
  • When the mouse is raised at the end of drawing, save the current canvas state tohistory.
  /** * Save the current canvas state *@private* /
  private addHistoy() {
    if (
      this.screenShortCanvas ! =null &&
      this.screenShortController.value ! =null
    ) {
      // Get canvas and container
      const context = this.screenShortCanvas;
      const controller = this.screenShortController.value;
      if (this.history.length > this.maxUndoNum) {
        // Delete the earliest canvas record
        this.history.unshift();
      }
      // Save the current canvas state
      this.history.push({
        data: context.getImageData(0.0, controller.width, controller.height)
      });
      // Enable the undo button
      this.data.setUndoStatus(true); }}Copy the code
  • When the mouse is moving, we take outhistoryIs the last record in.
  /** * Displays the latest canvas state *@private* /
  private showLastHistory() {
    if (this.screenShortCanvas ! =null) {
      const context = this.screenShortCanvas;
      if (this.history.length <= 0) {
        this.addHistoy();
      }
      context.putImageData(this.history[this.history.length - 1] ["data"].0.0); }}Copy the code

The above functions can be executed at an appropriate time to solve the problem of repeated drawing of graphs. Next, let’s look at the drawing effect after solving, as shown below:

Realize rectangle drawing

In the previous analysis, we got the coordinates of the starting point of the mouse and the coordinates when the mouse moved. We can calculate the width and height of the selected area of the box through these data, as shown below.

// Get the mouse starting point coordinates
const { startX, startY } = this.drawGraphPosition;
// Get the current mouse coordinates
const currentX = nonNegativeData(event.offsetX);
const currentY = nonNegativeData(event.offsetY);
// Cut the temporary width and height of the frame
const tempWidth = currentX - startX;
const tempHeight = currentY - startY;
Copy the code

After we get this data, we can draw a rectangle using the Canvas RECt API. The code is as follows:

/** * Draw rectangle *@param mouseX
 * @param mouseY
 * @param width
 * @param height
 * @param Color Border color *@param BorderWidth Border size *@param Context is the canvas that needs to be drawn@param Controller Canvas container * to operate on@param ImageController Image canvas container */
export function drawRectangle(
  mouseX: number,
  mouseY: number,
  width: number,
  height: number,
  color: string,
  borderWidth: number,
  context: CanvasRenderingContext2D,
  controller: HTMLCanvasElement,
  imageController: HTMLCanvasElement
) {
  context.save();
  // Set the border color
  context.strokeStyle = color;
  // Set the border size
  context.lineWidth = borderWidth;
  context.beginPath();
  // Draw a rectangle
  context.rect(mouseX, mouseY, width, height);
  context.stroke();
  // The drawing is finished
  context.restore();
  // Use drawImage to draw the image below the mask
  context.save();
  context.globalCompositeOperation = "destination-over";
  context.drawImage(
    imageController,
    0.0, controller? .width, controller? .height );// The drawing is finished
  context.restore();
}

Copy the code

Ellipse rendering

When drawing an ellipse, we need to calculate the radius of the circle and the coordinates of the center of the circle according to the coordinate information, and then call the ellipse function to draw an ellipse. The code is as follows:

/** * Draw circle *@param Context the canvas that needs to be drawn *@param MouseX Current mouse X-axis coordinates *@param MouseY Indicates the y axis of the current mouse@param MouseStartX X coordinate * when the mouse is held down@param MouseStartY Y coordinate * when the mouse is held down@param BorderWidth borderWidth *@param Color Border color */
export function drawCircle(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  mouseStartX: number,
  mouseStartY: number,
  borderWidth: number,
  color: string
) {
  // The coordinate boundary processing solves the error problem when the reverse ellipse drawing
  const startX = mouseX < mouseStartX ? mouseX : mouseStartX;
  const startY = mouseY < mouseStartY ? mouseY : mouseStartY;
  const endX = mouseX >= mouseStartX ? mouseX : mouseStartX;
  const endY = mouseY >= mouseStartY ? mouseY : mouseStartY;
  // Calculate the radius of the circle
  const radiusX = (endX - startX) * 0.5;
  const radiusY = (endY - startY) * 0.5;
  // Calculate the x and y coordinates of the center of the circle
  const centerX = startX + radiusX;
  const centerY = startY + radiusY;
  // Start drawing
  context.save();
  context.beginPath();
  context.lineWidth = borderWidth;
  context.strokeStyle = color;

  if (typeof context.ellipse === "function") {
    // Draw a circle with a rotation Angle of 0 and an end Angle of 2*PI
    context.ellipse(centerX, centerY, radiusX, radiusY, 0.0.2 * Math.PI);
  } else {
    throw "Your browser does not support ellipse, so you cannot draw ellipses.";
  }
  context.stroke();
  context.closePath();
  // Finish drawing
  context.restore();
}

Copy the code

⚠️ annotation has been written very clearly, the API used here are: beginPath, lineWidth, ellipse, closePath, developers who are not familiar with these apis, please step to the specified position to consult.

Implementation of arrow drawing

Arrow drawing is the most complicated tool compared to other tools, because we need to use trigonometric functions to calculate the coordinates of the two points of the arrow, and use the arc tangent function of trigonometric functions to calculate the Angle of the arrow

Since we need to use trigonometric functions to do this, let’s take a look at what we know:

  The length of the line from P3 to P2. P4 is symmetric with P3, so the length from P4 to P2 is equal to the length from P3 to P2 * 3. The Angle between the arrow slant line P3 and the lines P1 and P2 (θ) is symmetric, so the Angle between P4 and the lines P1 and P2 is equal * Find: * coordinates of P3 and P4 */
Copy the code

As shown in the figure above, P1 is the coordinate when the mouse is held down, P2 is the coordinate when the mouse is moved, and the Angle θ is 30. After we know these information, we can work out the coordinates of P3 and P4, and then we can draw arrows by moveTo and lineTo on canvas.

The implementation code is as follows:

/** * Draw arrow *@param Context the canvas that needs to be drawn *@param MouseStartX x coordinate P1 * when the mouse is down@param MouseStartY click on the y axis P1 *@param MouseX current mouse X-axis coordinate P2 *@param MouseY current mouseY coordinate P2 *@param Theta arrow slash the Angle (theta) and linear P3 - > (P1, P2) | | P4 - > P1 (P1, P2) *@param Headlen arrow slash the length of the P3 - > P2 | | P4 - > P2 *@param BorderWidth borderWidth *@param Color Border color */
export function drawLineArrow(
  context: CanvasRenderingContext2D,
  mouseStartX: number,
  mouseStartY: number,
  mouseX: number,
  mouseY: number,
  theta: number,
  headlen: number,
  borderWidth: number,
  color: string
) {
  Known: / * * * * 1. * 2. The coordinates of P1 and P2 arrow slash (P3 | | P4) - > P2 the length of the straight line * 3. Arrow slash (P3 | | P4) - > (P1, P2) the Angle (theta) linear: * o * the coordinates of P3 or P4 * /
  const angle =
      (Math.atan2(mouseStartY - mouseY, mouseStartX - mouseX) * 180) / Math.PI, // Use atan2 to get the Angle of the arrow
    angle1 = ((angle + theta) * Math.PI) / 180.// The Angle of P3
    angle2 = ((angle - theta) * Math.PI) / 180.// P4 point Angle
    topX = headlen * Math.cos(angle1), // the x coordinate of P3
    topY = headlen * Math.sin(angle1), // the y-coordinate of P3
    botX = headlen * Math.cos(angle2), // The X coordinate at P4
    botY = headlen * Math.sin(angle2); // The y-coordinate of P4

  // Start drawing
  context.save();
  context.beginPath();

  // The coordinates of P3
  let arrowX = mouseStartX - topX,
    arrowY = mouseStartY - topY;

  // Move the stroke to P3
  context.moveTo(arrowX, arrowY);
  // Move the stroke to P1
  context.moveTo(mouseStartX, mouseStartY);
  // Draw the line from P1 to P2
  context.lineTo(mouseX, mouseY);
  // Calculate the position of P3
  arrowX = mouseX + topX;
  arrowY = mouseY + topY;
  // Move the stroke to P3
  context.moveTo(arrowX, arrowY);
  // Draw the slash from P2 to P3
  context.lineTo(mouseX, mouseY);
  // Calculate the position of P4
  arrowX = mouseX + botX;
  arrowY = mouseY + botY;
  // Draw the slash from P2 to P4
  context.lineTo(arrowX, arrowY);
  / / color
  context.strokeStyle = color;
  context.lineWidth = borderWidth;
  / / fill
  context.stroke();
  // Finish drawing
  context.restore();
}

Copy the code

The new apis used here are: moveTo and lineTo. If you are not familiar with these apis, please go to the specified location to check them.

Achieve brush drawing

We need to use lineTo to draw the brush, but we need to pay attention to this when drawing: When the mouse is pressed, we need to use beginPath to clear a path, and move the brush to the position when the mouse is pressed, otherwise the starting position of the mouse is always 0, and the bug is as follows:

To fix this bug, initialize the stroke position when the mouse is down.

/** * Brush initialization */
export function initPencli(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number
) {
  / / | | clear a path
  context.beginPath();
  // Move the brush position
  context.moveTo(mouseX, mouseY);
}

Copy the code

Then, draw the line according to the coordinate information when the mouse is located. The code is as follows:

/** * Brush draw *@param context
 * @param mouseX
 * @param mouseY
 * @param size
 * @param color* /
export function drawPencli(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  size: number,
  color: string
) {
  // Start drawing
  context.save();
  // Set the border size
  context.lineWidth = size;
  // Set the border color
  context.strokeStyle = color;
  context.lineTo(mouseX, mouseY);
  context.stroke();
  // The drawing is finished
  context.restore();
}
Copy the code

Mosaic rendering

As we all know, a picture is made up of pixels. When we set the pixels in a certain area to the same color, the information in this area will be destroyed. The area destroyed by us is called Mosaic.

After knowing the principle of Mosaic, we can analyze the realization of the train of thought:

  • Get the image information of the mouse over the path area
  • Draws the pixels in the region in a similar color to their surroundings

The specific implementation code is as follows:

/** * Get the color of the image at the specified coordinates *@param ImgData Image * to operate@param X dot x dot x@param Y dot y dot */
const getAxisColor = (imgData: ImageData, x: number, y: number) = > {
  const w = imgData.width;
  const d = imgData.data;
  const color = [];
  color[0] = d[4 * (y * w + x)];
  color[1] = d[4 * (y * w + x) + 1];
  color[2] = d[4 * (y * w + x) + 2];
  color[3] = d[4 * (y * w + x) + 3];
  return color;
};

/** * Sets the color of the image at the specified coordinates *@param ImgData Image * to operate@param X dot x dot x@param Y, y point *@param Color Array of colors */
const setAxisColor = (
  imgData: ImageData,
  x: number,
  y: number,
  color: Array<number>
) = > {
  const w = imgData.width;
  const d = imgData.data;
  d[4 * (y * w + x)] = color[0];
  d[4 * (y * w + x) + 1] = color[1];
  d[4 * (y * w + x) + 2] = color[2];
  d[4 * (y * w + x) + 3] = color[3];
};

/** * draw a Mosaic ** 1. Obtain the image information * 2. Draws the pixels in the region with the same color as the surrounding pixels *@param MouseX Current mouse X-axis coordinates *@param MouseY Indicates the Y axis of the current mouse@param Size Mosaic brush size *@param DegreeOfBlur * degreeOfBlur in a Mosaic@param Context is the canvas to be drawn */
export function drawMosaic(
  mouseX: number,
  mouseY: number,
  size: number,
  degreeOfBlur: number,
  context: CanvasRenderingContext2D
) {
  // Get the image pixel information of the mouse over the area
  const imgData = context.getImageData(mouseX, mouseY, size, size);
  // Get the image width and height
  const w = imgData.width;
  const h = imgData.height;
  // Divide the image width and height equally
  const stepW = w / degreeOfBlur;
  const stepH = h / degreeOfBlur;
  // Loop the canvas pixels
  for (let i = 0; i < stepH; i++) {
    for (let j = 0; j < stepW; j++) {
      // Get a random color for a small square
      const color = getAxisColor(
        imgData,
        j * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur),
        i * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur)
      );
      // Loop the pixels of the small box
      for (let k = 0; k < degreeOfBlur; k++) {
        for (let l = 0; l < degreeOfBlur; l++) {
          // Set the color of the small squaressetAxisColor( imgData, j * degreeOfBlur + l, i * degreeOfBlur + k, color ); }}}}// Render the pixelated image information
  context.putImageData(imgData, mouseX, mouseY);
}

Copy the code

Realization of text drawing

Canvas does not directly provide API for us to input text, but it does provide API for filling text. Therefore, we need a div for the user to input text. After the user completes the input, the input text can be filled into the specified area.

The effects are as follows:

  • Create a div in the component, enable the editable property of the div, and lay out the style
<template>
  <teleport to="body">
		<! -- Text input area -->
    <div
      id="textInputPanel"
      ref="textInputController"
      v-show="textStatus"
      contenteditable="true"
      spellcheck="false"
    ></div>
  </teleport>
</template>
Copy the code
  • When the mouse is down, the text input area position is calculated
// Calculate the display position of the text box
const textMouseX = mouseX - 15;
const textMouseY = mouseY - 15;
// Modify the text area location
this.textInputController.value.style.left = textMouseX + "px";
this.textInputController.value.style.top = textMouseY + "px";
Copy the code
  • When the position of the input box changes, it means that the user has finished the input. The content entered by the user is rendered to the canvas. The code for drawing the text is as follows
/** * Draw text *@param Text Specifies the text * to be drawn@param MouseX draws the X-axis coordinates * of the position@param MouseY draws the Y-axis coordinates of the position *@param Color Font color *@param FontSize fontSize *@param Context needs the canvas that you're drawing */
export function drawText(
  text: string,
  mouseX: number,
  mouseY: number,
  color: string,
  fontSize: number,
  context: CanvasRenderingContext2D
) {
  // Start drawing
  context.save();
  context.lineWidth = 1;
  // Set the font color
  context.fillStyle = color;
  context.textBaseline = "middle";
  context.font = `bold ${fontSize}Px Microsoft Yahei ';
  context.fillText(text, mouseX, mouseY);
  // Finish drawing
  context.restore();
}

Copy the code

Implement download function

The download function is relatively simple. We just need to put the content of the clipped area into a new canvas, and then call the toDataURL method to get the base64 address of the image. We create a tag, add the Download property, and start the click event of the A tag to download.

The implementation code is as follows:

export function saveCanvasToImage(
  context: CanvasRenderingContext2D,
  startX: number,
  startY: number,
  width: number,
  height: number
) {
  // Get the picture information of the cropped box area
  const img = context.getImageData(startX, startY, width, height);
  // Create a Canvas tag to hold the images in the cropped area
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  // Get the clipping area canvas
  const imgContext = canvas.getContext("2d");
  if (imgContext) {
    // Put the picture in the cropping box
    imgContext.putImageData(img, 0.0);
    const a = document.createElement("a");
    // Get the image
    a.href = canvas.toDataURL("png");
    // Download the image
    a.download = `The ${new Date().getTime()}.png`; a.click(); }}Copy the code

Implement undo function

Since we draw the graph in history mode, each time we draw the graph, we will store the canvas state once. We only need to pop up the last record from history when we click the undo button.

The implementation code is as follows:

/** * retrieve a history */
private takeOutHistory() {
  const lastImageData = this.history.pop();
  if (this.screenShortCanvas ! =null && lastImageData) {
    const context = this.screenShortCanvas;
    if (this.undoClickNum == 0 && this.history.length > 0) {
      // For the first time, two historical records are required
      const firstPopImageData = this.history.pop() as Record<string.any>;
      context.putImageData(firstPopImageData["data"].0.0);
    } else {
      context.putImageData(lastImageData["data"].0.0); }}this.undoClickNum++;
  // The history has been taken, disable the recall button click
  if (this.history.length <= 0) {
    this.undoClickNum = 0;
    this.data.setUndoStatus(false); }}Copy the code

Realize the shutdown function

The shutdown function refers to resetting the screenshot component, so we need to push a destroy message to the parent component through the Emit.

The implementation code is as follows:

  /** * reset the component */
  private resetComponent = () = > {
    if (this.emit) {
      // Hide the screenshot toolbar
      this.data.setToolStatus(false);
      // Initialize the response variable
      this.data.setInitStatus(true);
      // Destroy the component
      this.emit("destroy-component".false);
      return;
    }
    throw "Component reset failed";
  };
Copy the code

Implement validation function

When the user clicks ok, we need to convert the content in the clipping box to Base64, then push the payment component through emit, and finally reset the component.

The implementation code is as follows:

const base64 = this.getCanvasImgData(false);
this.emit("get-image-data", base64);
Copy the code

Plug-in address

At this point, the plug-in implementation process is shared.

  • Plug-in online experience address: Chat-system

  • Plugins GitHub repository address: Screen-shot

  • Open source project address: Chat-System-Github

Write in the last

  • Feel free to correct any mistakes in the comments section, and if this post helped you, feel free to like and follow 😊
  • This article was originally published in Nuggets and cannot be reproduced without permission at 💌