I made an online whiteboard!!!

I believe that friends who write articles must have the need to draw pictures at ordinary times. The author usually uses an online hand-painted style whiteboard-- excalidraw There's nothing to say about the use experience, but there's a problem. It can't be saved in the cloud, but the good news is that it's open source, so I'm thinking about whether to make a cloud storage support based on it, so I wrote several interfaces-- Triazolam , although the function has been completed, the bad news is that excaliidraw is based on React, and the amount of code is very large. It is not very friendly to people who write Vue all year round. In addition, it can not be used on Vue projects. Therefore, being idle is also idle. The author spent almost a month's spare time to make a hasty version, which has nothing to do with the framework. It's quick to see it first:

You can also experience the online demo: https://wanglin2.github.io/tiny_whiteboard_demo/.

The source code warehouse is here: https://github.com/wanglin2/tiny_whiteboard.

Next, the author will roughly introduce the key technical points of implementation.

The drawings in this paper are drawn with the whiteboard developed by the author.

For simplicity, let's take a look at the general implementation of the whole process with [the life of a rectangle].

birth

Rectangle is about to be born in a canvas world called canvas, which is roughly like this:

<template>
  <div class="container">
    <div class="canvasBox" ref="box"></div>
  </div>
</template>

<script setup>
    import { onMounted, ref } from "vue";

    const container = ref(null);
    const canvas = ref(null);
    let ctx = null;
    const initCanvas = () => {
        let { width, height } = container.value.getBoundingClientRect();
        canvas.value.width = width;
        canvas.value.height = height;
        ctx = canvas.value.getContext("2d");
        // Move the origin of the canvas from the upper left corner to the center point
        ctx.translate(width / 2, height / 2);
    };

    onMounted(() => {
        initCanvas();
    });
</script>
copy

Why move the origin of the canvas world to the center? In fact, it is to facilitate the subsequent overall zoom in and out.

Rectangle wants to be born without one thing, event, otherwise the canvas can't feel our idea of creating rectangle.

// ...
const bindEvent = () => {
    canvas.value.addEventListener("mousedown", onMousedown);
    canvas.value.addEventListener("mousemove", onMousemove);
    canvas.value.addEventListener("mouseup", onMouseup);
};
const onMousedown = (e) => {};
const onMousemove = (e) => {};
const onMouseup = (e) => {};

onMounted(() => {
    initCanvas();
    bindEvent();// ++
});
copy

If a rectangle wants to exist in the canvas world, it needs to be clear about "how big" and "where". How big is its width and height, and where is its x and y.

When we press the mouse on the canvas world, we decide where the rectangle is born, so we need to record this position:

let mousedownX = 0;
let mousedownY = 0;
let isMousedown = false;
const onMousedown = (e) => {
    mousedownX = e.clientX;
    mousedownY = e.clientY;
    isMousedown = true;
};
copy

When we not only press the mouse, but also start to move in the canvas world, we will create a rectangle. In fact, we can create countless rectangles. They have something in common. Just like our men, good men and bad men have two eyes and one mouth. The difference is that some people have bigger eyes and some people are more rhetoric, so they have a model:

// Rectangular element class
class Rectangle {
    constructor(opt) {
        this.x = opt.x || 0;
        this.y = opt.y || 0;
        this.width = opt.width || 0;
        this.height = opt.height || 0;
    }
    render() {
        ctx.beginPath();
        ctx.rect(this.x, this.y, this.width, this.height);
        ctx.stroke();
    }
}
copy

After the rectangle is created, its initial size can be modified before our mouse is released:

// Currently active element
let activeElement = null;
// All elements
let allElements = [];
// Render all elements
const renderAllElements = () => {
  allElements.forEach((element) => {
    element.render();
  });
}

const onMousemove = (e) => {
    if (!isMousedown) {
        return;
    }
    // If the rectangle does not exist, create one first
    if (!activeElement) {
        activeElement = new Rectangle({
            x: mousedownX,
            y: mousedownY,
        });
        // Join the element family
        allElements.push(activeElement);
    }
    // Update the size of the rectangle
    activeElement.width = e.clientX - mousedownX;
    activeElement.height = e.clientY - mousedownY;
    // Render all elements
    renderAllElements();
};
copy

When our mouse was released, the rectangle was officially born~

const onMouseup = (e) => {
    isMousedown = false;
    activeElement = null;
    mousedownX = 0;
    mousedownY = 0;
};
copy

what?? Different from what we expected, first of all, our mouse moves in the upper left corner, but the rectangle is born in the middle. In addition, the process of changing the size of the rectangle is also shown, and we only need to see the size of the last minute.

In fact, our mouse is in another world. The coordinate origin of this world is in the upper left corner, and we moved the origin of the canvas world to the center. Therefore, although they are parallel worlds, the coordinate system is different, so we need to convert the position of our mouse into the position of the canvas:

const screenToCanvas = (x, y) => {
    return {
        x: x - canvas.value.width / 2,
        y: y - canvas.value.height / 2
    }
}
copy

Then turn the coordinates before rendering the rectangle:

class Rectangle {
    constructor(opt) {}

    render() {
        ctx.beginPath();
        // Convert screen coordinates to canvas coordinates
        let canvasPos = screenToCanvas(this.x, this.y);
        ctx.rect(canvasPos.x, canvasPos.y, this.width, this.height);
        ctx.stroke();
    }
}
copy

Another problem is that in the canvas world, when you draw something new, the original painting still exists, so you need to empty the canvas before repainting all elements:

const clearCanvas = () => {
    let width = canvas.value.width;
    let height = canvas.value.height;
    ctx.clearRect(-width / 2, -height / 2, width, height);
};
copy

Empty the canvas world before rendering the rectangle each time:

const renderAllElements = () => {
  clearCanvas();// ++
  allElements.forEach((element) => {
    element.render();
  });
}
copy

Congratulations on your successful birth~

grow up

Fix it

When I was a child, I was repaired by my parents. When I grew up, I was repaired by the world. Everything has been changing since I was born. Time will smooth your edges and corners and increase your weight. As the controller of the canvas world, what should we do when we want to repair a rectangle? The first step is to select it, and the second step is to repair it.

1. First, select it

How to select a rectangle in the vast rectangular sea? It's very simple. If the mouse hits the border of a rectangle, it means that it is selected. The rectangle is actually four line segments, so just judge whether the mouse clicks on a line segment, and the problem turns into how to judge whether a point is close to a line segment. Because a line is very narrow, it's very difficult for the mouse to accurately click on it, Therefore, we might as well think that the click position of the mouse is within 10px of the target.

First, we can judge the distance between a point and a straight line according to the calculation formula from point to straight line:

The distance formula from the point to the straight line is:

// Calculate the distance from the point to the line
const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
  // The straight line formula y=kx+b is not applicable to the case where the straight line is perpendicular to the x axis, so the case where the straight line is perpendicular to the x axis is treated separately
  if (x1 === x2) {
    return Math.abs(x - x1);
  } else {
    let k, b;
    // Y1 = k * X1 + B / / 0
    // B = Y1 - k * X1 / / Formula 1

    // Y2 = k * x2 + B / / formula 2
    // Y2 = k * x2 + Y1 - k * X1 / / replace Formula 1 with formula 2
    // y2 - y1 = k * x2 - k * x1
    // y2 - y1 = k * (x2 -  x1)
    k = (y2 - y1) / (x2 -  x1) // Type 3

    b = y1 - k * x1  // Replace form 3 with form 0
    
    return Math.abs((k * x - y + b) / Math.sqrt(1 + k * k));
  }
};
copy

However, this is not enough, because the following conditions are obviously met, but the line segment should not be considered to have been hit:

Because the straight line is infinitely long rather than a line segment, we need to judge the distance between the point and the two endpoints of the line segment. The distance between the point and the two endpoints needs to meet the conditions. The following figure is the farthest distance allowed between a point and one endpoint of the line segment:

It is easy to calculate the distance between two points. The formula is as follows:

In this way, we can get our final function:

// Check whether a line segment is clicked
const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
  // The distance from the point to the straight line does not meet the requirement of direct return
  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
    return false;
  }
  // Distance from point to two endpoints
  let dis1 = getTowPointDistance(x, y, x1, y1);
  let dis2 = getTowPointDistance(x, y, x2, y2);
  // The distance between the two endpoints of the line segment, that is, the length of the line segment
  let dis3 = getTowPointDistance(x1, y1, x2, y2);
  // Calculate the bevel length according to Pythagorean theorem, that is, the farthest distance allowed
  let max = Math.sqrt(dis * dis + dis3 * dis3);
  // The distance between the point and both endpoints needs to be less than this maximum distance
  if (dis1 <= max && dis2 <= max) {
    return true;
  }
  return false;
};

// Calculate the distance between two points
const getTowPointDistance = (x1, y1, x2, y2) => {
  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}
copy

Then add a method to our rectangular mold:

class Rectangle {
    // Detect whether it was hit
    isHit(x0, y0) {
        let { x, y, width, height } = this;
        // Line segments on four sides of a rectangle
        let segments = [
            [x, y, x + width, y],
            [x + width, y, x + width, y + height],
            [x + width, y + height, x, y + height],
            [x, y + height, x, y],
        ];
        for (let i = 0; i < segments.length; i++) {
            let segment = segments[i];
            if (
                checkIsAtSegment(x0, y0, segment[0], segment[1], segment[2], segment[3])
            ) {
                return true;
            }
        }
        return false;
    }
}
copy

Now we can modify the mouse button function to determine whether we hit a rectangle:

const onMousedown = (e) => {
  // ...
  if (currentType.value === 'selection') {
    // Element activation detection in selected mode
    checkIsHitElement(mousedownX, mousedownY);
  }
};

// Detect whether an element has been hit
const checkIsHitElement = (x, y) => {
  let hitElement = null;
  // Traverse the elements from back to front, that is, by default, the new element is considered to be at a higher level
  for (let i = allElements.length - 1; i >= 0; i--) {
    if (allElements[i].isHit(x, y)) {
      hitElement = allElements[i];
      break;
    }
  }
  if (hitElement) {
    alert("Hit the rectangle");
  }
};
copy

It can be seen that although we successfully selected a rectangle, we accidentally created a new rectangle. To avoid this situation, we can add a variable to distinguish whether to create a rectangle or select a rectangle, and do the right thing at the right time:

<template>
  <div class="container" ref="container">
    <canvas ref="canvas"></canvas>
    <div class="toolbar">
      <el-radio-group v-model="currentType">
        <el-radio-button label="selection">choice</el-radio-button>
        <el-radio-button label="rectangle">rectangle</el-radio-button>
      </el-radio-group>
    </div>
  </div>
</template>

<script setup>
// ...
// Current operating mode
const currentType = ref('selection');
</script>
copy

In the selection mode, you can select a rectangle, but you cannot create a new rectangle. Modify the mouse movement method:

const onMousemove = (e) => {
  if (!isMousedown || currentType.value === 'selection') {
    return;
  }
}
copy

Finally, when a rectangle is selected, in order to highlight its selection and then repair it, we draw a dotted box around it and add some operation handles. First, add an attribute to the rectangular mold to represent that it is activated:

class Rectangle {
  constructor(opt) {
    // ...
    this.isActive = false;
  }
}
copy

Then add a method to it to render the active graphics when activated:

class Rectangle {
  render() {
    let canvasPos = screenToCanvas(this.x, this.y);
    drawRect(canvasPos.x, canvasPos.y, this.width, this.height);
    this.renderActiveState();// ++
  }

  // Render active when active
  renderActiveState() {
    if (!this.isActive) {
      return;
    }
    let canvasPos = screenToCanvas(this.x, this.y);
    // In order not to overlap the rectangle, the dashed box is one circle larger than the rectangle and the inner margin is increased by 5px
    let x = canvasPos.x - 5;
    let y = canvasPos.y - 5;
    let width = this.width + 10;
    let height = this.height + 10;
    // Dashed box of body
    ctx.save();
    ctx.setLineDash([5]);
    drawRect(x, y, width, height);
    ctx.restore();
    // Operating handle in the upper left corner
    drawRect(x - 10, y - 10, 10, 10);
    // Operating handle in the upper right corner
    drawRect(x + width, y - 10, 10, 10);
    // Operating handle in the lower right corner
    drawRect(x + width, y + height, 10, 10);
    // Operating handle in the lower left corner
    drawRect(x - 10, y + height, 10, 10);
    // Rotate the operating handle
    drawCircle(x + width / 2, y - 10, 10);
  }
}

// Extract the common method of drawing rectangle and circle
// draw rectangle
const drawRect = (x, y, width, height) => {
  ctx.beginPath();
  ctx.rect(x, y, width, height);
  ctx.stroke();
};
// Draw circle
const drawCircle = (x, y, r) => {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2 * Math.PI);
  ctx.stroke();
};
copy

Finally, modify the method to detect whether the element is hit:

const checkIsHitElement = (x, y) => {
  // ...
  // If an active element already exists, deactivate it first
  if (activeElement) {
    activeElement.isActive = false;
  }
  // Update current active element
  activeElement = hitElement;
  if (hitElement) {
    // If an element is currently hit, change its state to active
    hitElement.isActive = true;
  }
  // Re render all elements
  renderAllElements();
};
copy

It can be seen that the activation of the new rectangle does not cancel the previous activation element. The reason is our processing function when the mouse is released, because our previous processing is to reset the activeElement to null when the mouse is released. Modify it:

const onMouseup = (e) => {
  isMousedown = false;
  // There is no need to reset in the selected mode
  if (currentType.value !== 'selection') {
    activeElement = null;
  }
  mousedownX = 0;
  mousedownY = 0;
};
copy

2. The second step is to repair it

At last, we have reached the attention-seeking repair link. But don't worry. Before the repair, we have to know which operation handle our mouse is on. When we activate a rectangle, it will display the active state, and then when we press and hold a part of the active state to drag, we will carry out specific repair operations. For example, press and hold the large dotted line box in the middle to move, Press and hold the rotation handle to rotate the rectangle, and press and hold one of the other four corners to resize the rectangle.

Specifically, the dotted line box in the middle and the adjustment handles at the four corners are used to judge whether a point is within the rectangle. This is very simple:

// Judge whether a coordinate is within a rectangle
const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
};
copy

If the rotation button is a circle, we just need to judge the distance from a point to its center. If it is less than the radius, it means it is in the circle. Then we can add the detection method of each area in the active state to the rectangular mold:

class Rectangle {
  // Detect whether an active area has been hit
  isHitActiveArea(x0, y0) {
    let x = this.x - 5;
    let y = this.y - 5;
    let width = this.width + 10;
    let height = this.height + 10;
    if (checkPointIsInRectangle(x0, y0, x, y, width, height)) {
      // Dotted box in the middle
      return "body";
    } else if (getTowPointDistance(x0, y0, x + width / 2, y - 10) <= 10) {
      // Turning the handle
      return "rotate";
    } else if (checkPointIsInRectangle(x0, y0, x + width, y + height, 10, 10)) {
      // Operate the handle in the lower right corner
      return "bottomRight";
    }
  }
}
copy

For simplicity, we only demonstrate one of the four corner operation handles in the lower right corner. The other three are the same. You can improve them by yourself.

Next, we need to modify the method of mouse pressing. If the current mode is the selection mode and there is already an active rectangle, we will judge whether to press and hold an active area of the active rectangle. If it is indeed pressed in an active area, we will set two flag bits to record whether it is currently in the adjustment state of the rectangle and which area it is in, Otherwise, update the original rectangle logic currently activated:

// Is the element currently being adjusted
let isAdjustmentElement = false;
// Which area of the active state of the active element is currently pressed and held
let hitActiveElementArea = "";

const onMousedown = (e) => {
  mousedownX = e.clientX;
  mousedownY = e.clientY;
  isMousedown = true;
  if (currentType.value === "selection") {
    // Element activation detection in selected mode
    if (activeElement) {
      // If there are currently active elements, judge whether an area in the active state is pressed and held
      let hitActiveArea = activeElement.isHitActiveArea(mousedownX, mousedownY);
      if (hitActiveArea) {
        // Press and hold an active area
        isAdjustmentElement = true;
        hitActiveElementArea = hitArea;
        alert(hitActiveArea);
      } else {
        // Otherwise, update the active element
        checkIsHitElement(mousedownX, mousedownY);
      }
    } else {
      checkIsHitElement(mousedownX, mousedownY);
    }
  }
};
copy

When the mouse presses and holds an area in the active state of the rectangle and the mouse starts to move, it means that the rectangle repair operation is carried out. Let's first look at the rectangle movement operation when the dotted box is pressed and held.

Move rectangle

Moving a rectangle is very simple. You can modify its x and Y. first calculate the difference between the current position of the mouse and the position when the mouse is pressed, and then add the difference to the x and y of the rectangle at the moment when the mouse is pressed as the new coordinates of the rectangle. Before that, you have to modify our rectangular mold:

class Rectangle {
  constructor(opt) {
    this.x = opt.x || 0;
    this.y = opt.y || 0;
    // Record the initial position of the rectangle
    this.startX = 0;// ++
    this.startY = 0;// ++
    // ...
  }
    
  // Save the state of the rectangle at a certain moment
  save() {
    this.startX = this.x;
    this.startY = this.y;
  }

  // Move rectangle
  moveBy(ox, oy) {
    this.x = this.startX + ox;
    this.y = this.startY + oy;
  }
}
copy

When to save the state of the rectangle? Of course, when the mouse presses and holds an area of the rectangle active state:

const onMousedown = (e) => {
    // ...
    if (currentType.value === "selection") {
        if (activeElement) {
            if (hitActiveArea) {
                // Press and hold an active area
                isAdjustmentElement = true;
                hitActiveElementArea = hitArea;
                activeElement.save();// ++
            }
        }
        // ...
    }
}
copy

Then, when the mouse moves, you can perform the following movement operations:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      // Adjust in element
      let ox = e.clientX - mousedownX;
      let oy = e.clientY - mousedownY;
      if (hitActiveElementArea === "body") {
        // Move operation
        activeElement.moveBy(ox, oy);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}
copy

Don't forget to restore the flag bit when the mouse is released:

const onMouseup = (e) => {
  // ...
  if (isAdjustmentElement) {
    isAdjustmentElement = false;
    hitActiveElementArea = "";
  }
};
copy
Rotate rectangle

First, modify the rectangular mold and add the rotation angle attribute to it:

class Rectangle {
    constructor(opt) {
        // ...
        // Rotation angle
        this.rotate = opt.rotate || 0;
        // Record the initial angle of the rectangle
        this.startRotate = 0;
    }
}
copy

Then modify its rendering method:

class Rectangle {
    render() {
        ctx.save();// ++
        let canvasPos = screenToCanvas(this.x, this.y);
        ctx.rotate(degToRad(this.rotate));// ++
        drawRect(canvasPos.x, canvasPos.y, this.width, this.height);
        this.renderActiveState();
        ctx.restore();// ++
    }
}
copy

The rotate method of the canvas receives the value in radians. We save the angle value, so we need to convert the angle into radians. The mutual conversion formula of angle and radian is as follows:

Because 360 degrees=2PI
 180 degrees=PI
 So:

1 radian=(180/π)°angle
1 angle=π/180 radian
copy
// Radian rotation angle
const radToDeg = (rad) => {
  return rad * (180 / Math.PI);
};

// Angle to radian
const degToRad = (deg) => {
  return deg * (Math.PI / 180);
};
copy

Then, like the coordinate routine of the rectangle modified earlier, the initial angle is saved during rotation, and then the angle is updated during rotation:

class Rectangle {
    // Save the current state of the rectangle
    save() {
        // ...
        this.startRotate = this.rotate;
    }

    // Rotate rectangle
    rotateBy(or) {
        this.rotate = this.startRotate + or;
    }
}
copy

The next question is how to calculate the angle of mouse movement, that is, the angle from the position where the mouse is pressed to the position where the mouse is currently moving. There is no angle between the two points themselves, and only one relative central point will form an angle:

This center point is actually the center point of the rectangle. The included angle in the figure above can be calculated according to the difference between the line segment composed of these two points and the center point and the angle formed by the horizontal x axis:

The tangent value of these two included angles is equal to their opposite edge divided by the adjacent edge. We can calculate both the opposite edge and the adjacent edge, so we can calculate these two angles by using the arctangent function, and finally calculate the difference:

// Calculate the angle formed by two coordinates with the same center point
const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
  // The radian value is calculated, so it needs to be turned into an angle
  return radToDeg(Math.atan2(fy - cy, fx - cx) - Math.atan2(ty - cy, tx - cx));
}
copy

With this method, next we modify the mouse movement function:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // Move operation
      } else if (hitActiveElementArea === 'rotate') {
        // Perform the rotation operation
        // Center point of rectangle
        let center = getRectangleCenter(activeElement);
        // Gets the angle at which the mouse moves
        let or = getTowPointRotate(center.x, center.y, mousedownX, mousedownY, e.clientX, e.clientY);
        activeElement.rotateBy(or);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

// Calculates the center point of the rectangle
const getRectangleCenter = ({x, y, width, height}) => {
  return {
    x: x + width / 2,
    y: y + height / 2,
  };
}
copy

We can see that it does rotate, but obviously it is not the rotation we want. We want the rectangle to rotate with its own center, which is obviously not in the moving picture. In fact, this is because the rotate method of canvas canvas rotates with the canvas origin as the center. Therefore, when drawing the rectangle, you need to move the canvas origin to its own center, and then draw it. In this way, the rotation is equivalent to rotating with its own center, However, it should be noted that the origin changes, and the drawing coordinates of the rectangle itself and the relevant graphics in the active state need to be modified:

class Rectangle {
    render() {
        ctx.save();
        let canvasPos = screenToCanvas(this.x, this.y);
        // Move the canvas origin to its center
        let halfWidth = this.width / 2
        let halfHeight = this.height / 2
        ctx.translate(canvasPos.x + halfWidth, canvasPos.y + halfHeight);
        // rotate
        ctx.rotate(degToRad(this.rotate));
        // If the origin becomes its own center, its own coordinates x and y also need to be transformed, that is, canvas POS X - (canvaspos. X + halfwidth) actually becomes (- halfWidth, -halfHeight)
        drawRect(-halfWidth, -halfHeight, this.width, this.height);
        this.renderActiveState();
        ctx.restore();
    }

    renderActiveState() {
        if (!this.isActive) {
            return;
        }
        let halfWidth = this.width / 2     // ++
        let halfHeight = this.height / 2   // ++
        let x = -halfWidth - 5;            // this.x -> -halfWidth
        let y = -halfHeight - 5;           // this.y -> -halfHeight
        let width = this.width + 10;
        let height = this.height + 10;
        // ...
    }
}
copy
Problems after rotation

After the rectangle rotates, we will find a problem. We clearly click on the border of the progress, but we can't activate it. Does the rectangle want to get rid of our control? It thinks too much for the simple reason:

The dotted line is the position when the rectangle is not rotated. We click on the frame after rotation, but our click detection is carried out when the rectangle is not rotated. Although the rectangle is rotated, its x and y coordinates have not changed in essence. It is very simple to know the reason and solve the problem. We might as well rotate the coordinates of the mouse pointer reversely with the center of the rectangle as the origin and the rotation angle of the rectangle:

Well, the problem turns to how to find a coordinate after rotating a specified angle:

As shown in the figure above, calculate the P2 coordinate after p1 rotates the black angle counterclockwise with o as the center. First, calculate the arctangent value of the green angle according to the p1 coordinate, and then add the known rotation angle to obtain the red angle. No matter how it rotates, the distance between this point and the central point is unchanged, so we can calculate the distance from p1 to the central point O, that is, the distance from P2 to point O, and the length of the bevel, The red angle is also known, so as long as the length of the opposite side and the adjacent side can be calculated according to the sine and cosine theorem, the natural coordinates of P2 will be known:

// Gets the coordinates of the specified angle of rotation of the coordinates through the specified center point
const getRotatedPoint = (x, y, cx, cy, rotate) => {
  let deg = radToDeg(Math.atan2(y - cy, x - cx));
  let del = deg + rotate;
  let dis = getTowPointDistance(x, y, cx, cy);
  return {
    x: Math.cos(degToRad(del)) * dis + cx,
    y: Math.sin(degToRad(del)) * dis + cy,
  };
};
copy

Finally, modify the rectangle click detection method:

class Rectangle {
    // Detect whether it was hit
    isHit(x0, y0) {
        // Rotates the angle of the rectangle in reverse
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }

    // Detect whether an active area has been hit
    isHitActiveArea(x0, y0) {
        // Rotates the angle of the rectangle in reverse
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }
}
copy
Telescopic rectangle

The last way to repair the rectangle is to expand the rectangle, that is, adjust the size of the rectangle, as shown in the following figure:

The dotted line is the rectangle before stretching, and the solid line is the new rectangle after pressing and holding the stretching handle at the lower right corner of the rectangle. The rectangle is composed of four attributes: x, y, width and height. Therefore, calculating the stretched rectangle is actually calculating the x, y, width and height of the new rectangle. The calculation steps are as follows (the following idea comes from https://github.com/shenhudong/snapping-demo/wiki/corner-handle. ):

1. After pressing the telescopic handle with the mouse, calculate the diagonal point coordinates of this corner of the rectangle:

2. The center point of the new rectangle newCenter can be calculated according to the current position of the mouse and the diagonal point:

3. When the new center point is known, we can rotate the current coordinates of the mouse reversely with the angle of the element at the new center point to obtain the coordinates of the lower right corner when the new rectangle is not rotated rp:

4. With the coordinates of the center point and the coordinates of the lower right corner, it is easy to calculate the x, y, wdith and height of the new rectangle:

let width = (rp.x - newCenter.x) * 2
let height = (rp.y- newCenter.y * 2
let x = rp.x - width
let y = rp.y - height
copy

Next, let's look at the code implementation. First, modify the rectangular model and add several attributes:

class Rectangle {
    constructor(opt) {
        // ...
        // Diagonal coordinates
        this.diagonalPoint = {
            x: 0,
            y: 0
        }
        // The difference between the mouse down position and the angular coordinates of the element. Because we press and hold the drag handle, the down position is a certain distance from the angular coordinates of the element. Therefore, in order to avoid sudden change, we need to record the difference
        this.mousedownPosAndElementPosOffset = {
            x: 0,
            y: 0
        }
    }
}
copy

Then modify the save method of rectangle save status:

class Rectangle {
  // Save the current state of the rectangle
  save(clientX, clientY, hitArea) {// Add several input parameters
    // ...
    if (hitArea === "bottomRight") {
      // Coordinates of the center point of the rectangle
      let centerPos = getRectangleCenter(this);
      // Coordinates of the lower right corner of the rectangle
      let pos = {
        x: this.x + this.width,
        y: this.y + this.height,
      };
      // If the element is rotated, the coordinates in the lower right corner should also be rotated accordingly
      let rotatedPos = getRotatedPoint(pos.x, pos.y, centerPos.x, centerPos.y, this.rotate);
      // Calculate the coordinates of the diagonal point
      this.diagonalPoint.x = 2 * centerPos.x - rotatedPos.x;
      this.diagonalPoint.y = 2 * centerPos.y - rotatedPos.y;
      // Calculate the difference between the mouse down position and the upper left coordinate of the element
      this.mousedownPosAndElementPosOffset.x = clientX - rotatedPos.x;
      this.mousedownPosAndElementPosOffset.y = clientY - rotatedPos.y;
    }
  }
}
copy

Several parameters are added to the save method, so the method of pressing the mouse should be modified accordingly. When calling save, the current position of the mouse and the active area should be passed in.

Next, we'll add another expansion method to the rectangular mold:

class Rectangle {
  // Stretch
  stretch(clientX, clientY, hitArea) {
    // Subtract the offset from the current coordinates of the mouse to get the coordinates of this corner of the rectangle
    let actClientX = clientX - this.mousedownPosAndElementPosOffset.x;
    let actClientY = clientY - this.mousedownPosAndElementPosOffset.y;
    // New center point
    let newCenter = {
      x: (actClientX + this.diagonalPoint.x) / 2,
      y: (actClientY + this.diagonalPoint.y) / 2,
    };
    // Obtain the new angular coordinates, the coordinates after the new center point reversely rotates the angle of the element, and obtain the angular coordinates before the rectangle is rotated
    let rp = getRotatedPoint(
      actClientX,
      actClientY,
      newCenter.x,
      newCenter.y,
      -this.rotate
    );
    if (hitArea === "bottomRight") {
      // Calculate new size
      this.width = (rp.x - newCenter.x) * 2;
      this.height = (rp.y - newCenter.y) * 2;
      // Calculate new location
      this.x = rp.x - this.width;
      this.y = rp.y - this.height;
    }
  }
}
copy

Finally, let's call this method in the mouse movement function:

const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // Move operation
      } else if (hitActiveElementArea === 'rotate') {
        // Perform the rotation operation
      } else if (hitActiveElementArea === 'bottomRight') {
        // Perform telescopic operation
        activeElement.stretch(e.clientX, e.clientY, hitActiveElementArea);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}
copy

The world is too small

One day, our little rectangle said that the world is so big that it wants to see it. Indeed, the screen is so large, and the rectangle must have been tired of waiting. As a universal canvas controller, let's meet its requirements.

We add two new state variables: scrollX and scrollY, which record the horizontal and vertical scrolling offsets of the canvas. The offset in the vertical direction is introduced. When the mouse scrolls, scrollY is increased or decreased. However, this scrolling value is not directly applied to the canvas, but added when drawing a rectangle. For example, y used for the rectangle is 100, and we scroll up 100px, then y=100-100=0 when drawing the actual rectangle, This achieves the effect that the rectangle also rolls with it.

// Current scroll value
let scrollY = 0;

// Listening events
const bindEvent = () => {
  // ...
  canvas.value.addEventListener("mousewheel", onMousewheel);
};

// Mouse movement event
const onMousewheel = (e) => {
  if (e.wheelDelta < 0) {
    // Scroll down
    scrollY += 50;
  } else {
    // scroll up
    scrollY -= 50;
  }
  // Re render all elements
  renderAllElements();
};
copy

Then we add this scroll offset when drawing the rectangle:

class Rectangle {
    render() {
        ctx.save();
        let _x = this.x;
        let _y = this.y - scrollY;
        let canvasPos = screenToCanvas(_x, _y);
        // ...
    }
}
copy

Isn't it very simple, but the problem comes again, because after scrolling, we will find that we can't activate the rectangle again, and there is a problem in drawing the rectangle:

The reason is the same as the rectangle rotation. Scrolling only adds the scrolling value during the final drawing, but the x and y of the rectangle still do not change, because scrollY is subtracted during the drawing, so we can add scrollY to the client y of the mouse, which just offsets. Modify the functions of mouse press and mouse movement:

const onMousedown = (e) => {
    let _clientX = e.clientX;
    let _clientY = e.clientY + scrollY;
    mousedownX = _clientX;
    mousedownY = _clientY;
    // ...
}

const onMousemove = (e) => {
    if (!isMousedown) {
        return;
    }
    let _clientX = e.clientX;
    let _clientY = e.clientY + scrollY;
    if (currentType.value === "selection") {
        if (isAdjustmentElement) {
            let ox = _clientX - mousedownX;
            let oy = _clientY - mousedownY;
            if (hitActiveElementArea === "body") {
                // Move operation
            } else if (hitActiveElementArea === "rotate") {
                // ...
                let or = getTowPointRotate(
                  center.x,
                  center.y,
                  mousedownX,
                  mousedownY,
                  _clientX,
                  _clientY
                );
                // ...
            }
        }
    }
    // ...
    // Update the size of the rectangle
      activeElement.width = _clientX - mousedownX;
      activeElement.height = _clientY - mousedownY;
    // ...
}
copy

Anyway, change all the previous places where e.clientY is used to the value after scrollY is added.

Absence makes the heart grow fonder

Sometimes the rectangle is too small. We want to see it close. Sometimes it's too large. We want to stay away. What can we do? It's very simple. Add a zoom in and zoom out function!

Add a new variable scale:

// Current zoom value
let scale = 1;
copy

Then we can zoom the canvas before drawing elements:

// Render all elements
const renderAllElements = () => {
  clearCanvas();
  ctx.save();// ++
  // Global zoom
  ctx.scale(scale, scale);// ++
  allElements.forEach((element) => {
    element.render();
  });
  ctx.restore();// ++
};
copy

Add two buttons and two zoom in and zoom out functions:

// enlarge
const zoomIn = () => {
  scale += 0.1;
  renderAllElements();
};

// narrow
const zoomOut = () => {
  scale -= 0.1;
  renderAllElements();
};
copy

The problem comes again, friends. We can't activate the rectangle and create a new rectangle. There is an offset again:

It's also an old-fashioned reason. No matter how you scroll, scale and rotate, the x and y essence of the rectangle is unchanged. There's no way. Convert it:

Similarly, modify the clientX and clientY of the mouse. First convert the mouse coordinate to the canvas coordinate, then reduce the zoom value of the canvas, and finally convert to the screen coordinate:

const onMousedown = (e) => {
  // Process scaling
  let canvasClient = screenToCanvas(e.clientX, e.clientY);// Convert screen coordinates to canvas coordinates
  let _clientX = canvasClient.x / scale;// Reduces the zoom value of the canvas
  let _clientY = canvasClient.y / scale;
  let screenClient = canvasToScreen(_clientX, _clientY)// Canvas coordinates back to screen coordinates
  // Process scrolling
  _clientX = screenClient.x;
  _clientY = screenClient.y + scrollY;
  mousedownX = _clientX;
  mousedownY = _clientY;
  // ...
}
// The onMousemove method does the same
copy

Can you be neat

If we want to align the two rectangles, it is difficult to operate by hand. There are generally two solutions: one is to increase the adsorption function, and the other is through the grid. The adsorption function requires a certain amount of calculation. The performance that we are not rich is even worse, so we choose to use the grid.

Let's add a grid drawing method:

// Render mesh
const renderGrid = () => {
  ctx.save();
  ctx.strokeStyle = "#dfe0e1";
  let width = canvas.value.width;
  let height = canvas.value.height;
  // Horizontal line, draw from top to bottom
  for (let i = -height / 2; i < height / 2; i += 20) {
    drawHorizontalLine(i);
  }
  // Vertical line, drawn from left to right
  for (let i = -width / 2; i < width / 2; i += 20) {
    drawVerticalLine(i);
  }
  ctx.restore();
};
// Draw grid horizontal lines
const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  // Don't forget that you also need to subtract the scroll value to draw the grid
  let _i = i - scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / 2, _i);
  ctx.lineTo(width / 2, _i);
  ctx.stroke();
};
// Draw grid vertical lines
const drawVerticalLine = (i) => {
  let height = canvas.value.height;
  ctx.beginPath();
  ctx.moveTo(i, -height / 2);
  ctx.lineTo(i, height / 2);
  ctx.stroke();
};
copy

The code looks a lot, but the logic is very simple, that is, scan from top to bottom and from left to right, and then draw some grids before drawing elements:

const renderAllElements = () => {
  clearCanvas();
  ctx.save();
  ctx.scale(scale, scale);
  renderGrid();// ++
  allElements.forEach((element) => {
    element.render();
  });
  ctx.restore();
};
copy

When entering the page, call this method first to display the grid:

onMounted(() => {
  initCanvas();
  bindEvent();
  renderAllElements();// ++
});
copy

Although we have drawn the grid here, it is actually useless. It does not limit us. When we need to draw the grid, let the rectangle stick to the edge of the grid, so that we can easily align multiple rectangles.

How to do this is very simple, because the grid is also drawn from the upper left corner, so after we get the clientX and clientY of the mouse, we take the remainder of the grid size, and then subtract the remainder to obtain the grid coordinates that can be adsorbed recently:

As shown in the above figure, the grid size is 20, the mouse coordinates are (65,65), x and y are taken as the remainder, 65% 20 = 5, and then 5 is subtracted to obtain the adsorbed coordinates (60,60).

Next, modify the onMousedown and onMousemove functions. It should be noted that this adsorption is only used for drawing graphics. Click detection, and we still need to use the non adsorbed coordinates:

const onMousedown = (e) => {
    // Process scaling
    // ...
    // Process scrolling
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // Adsorb to grid
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    mousedownX = gridClientX;// Use the coordinates adsorbed to the grid instead
    mousedownY = gridClientY;
    // ...
    // We still use the coordinates for element detection later_ clientX,_ clientY, to save the coordinates of the current state of the rectangle, you need to use gridClientX and gridClientY instead
    activeElement.save(gridClientX, gridClientY, hitArea);
    // ...
}

const onMousemove = (e) => {
    // Process scaling
    // ...
    // Process scrolling
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // Adsorb to grid
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    // All the following coordinates are determined by_ clientX,_ Change client to gridClientX and gridClientY
}
copy

Of course, the above code is still insufficient. When we scroll or shrink, the grid will not be covered with pages:

It's not difficult to solve. For example, in the above figure, after shrinking, the horizontal line does not extend to both ends, because after shrinking, the width becomes smaller. Then we just need to make the width larger when drawing the horizontal line, so we can divide it by the scaling value:

const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  let _i = i + scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / scale / 2, _i);// ++
  ctx.lineTo(width / scale / 2, _i);// ++
  ctx.stroke();
};
copy

The same is true for vertical lines.

When rolling occurs, such as rolling down, the upper horizontal line is gone, so we just need to draw the upper horizontal line. We draw the horizontal line from - height/2 to height/2, then we start from - height/2 and then draw up:

const renderGrid = () => {
    // ...
    // level
    for (let i = -height / 2; i < height / 2; i += 20) {
        drawHorizontalLine(i);
    }
    // Draw a horizontal line above the excess as you roll down
    for (
        let i = -height / 2 - 20;
        i > -height / 2 + scrollY;
        i -= 20
    ) {
        drawHorizontalLine(i);
    }
    // ...
}
copy

You can read the source code or improve it yourself.

Take a picture

If we want to record the beauty of a rectangle at a certain time, what should we do? Simply export it into a picture.

Exporting pictures cannot simply export the canvas directly, because when we scroll or zoom in, the rectangles may be outside the canvas, or there may be only one small rectangle, and it is also true that we export the whole canvas. It is not necessary. We can first calculate the common outer bounding boxes of all rectangles, and then create a canvas of such a large size, and draw a copy of all elements in the canvas, Then export the canvas.

To calculate the outer bounding box of all elements, you can first calculate the coordinates of the four corners of each rectangle. Note that it is after rotation, and then recycle all elements for comparison to calculate minx, maxx, miny and maxy.

// Get the outermost bounding box information of multiple elements
const getMultiElementRectInfo = (elementList = []) => {
  if (elementList.length <= 0) {
    return {
      minx: 0,
      maxx: 0,
      miny: 0,
      maxy: 0,
    };
  }
  let minx = Infinity;
  let maxx = -Infinity;
  let miny = Infinity;
  let maxy = -Infinity;
  elementList.forEach((element) => {
    let pointList = getElementCorners(element);
    pointList.forEach(({ x, y }) => {
      if (x < minx) {
        minx = x;
      }
      if (x > maxx) {
        maxx = x;
      }
      if (y < miny) {
        miny = y;
      }
      if (y > maxy) {
        maxy = y;
      }
    });
  });
  return {
    minx,
    maxx,
    miny,
    maxy,
  };
}
// Obtain the coordinates of the four corners of the element and apply the rotated coordinates
const getElementCorners = (element) => {
  // top left corner
  let topLeft = getElementRotatedCornerPoint(element, "topLeft")
  // Upper right corner
  let topRight = getElementRotatedCornerPoint(element, "topRight");
  // lower left quarter
  let bottomLeft = getElementRotatedCornerPoint(element, "bottomLeft");
  // Lower right corner
  let bottomRight = getElementRotatedCornerPoint(element, "bottomRight");
  return [topLeft, topRight, bottomLeft, bottomRight];
}
// Get the four angular coordinates of the element after rotation
const getElementRotatedCornerPoint = (element, dir) => {
  // Element center point
  let center = getRectangleCenter(element);
  // An angular coordinate of an element
  let dirPos = getElementCornerPoint(element, dir);
  // Rotate the angle of the element
  return getRotatedPoint(
    dirPos.x,
    dirPos.y,
    center.x,
    center.y,
    element.rotate
  );
};
// Get the four angular coordinates of the element
const getElementCornerPoint = (element, dir) => {
  let { x, y, width, height } = element;
  switch (dir) {
    case "topLeft":
      return {
        x,
        y,
      };
    case "topRight":
      return {
        x: x + width,
        y,
      };
    case "bottomRight":
      return {
        x: x + width,
        y: y + height,
      };
    case "bottomLeft":
      return {
        x,
        y: y + height,
      };
    default:
      break;
  }
};
copy

There are many codes, but the logic is very simple. After calculating the outsourcing bounding box information of all elements, you can create a new canvas and draw the elements:

// Export as picture
const exportImg = () => {
  // Calculate the outsourcing bounding box information of all elements
  let { minx, maxx, miny, maxy } = getMultiElementRectInfo(allElements);
  let width = maxx - minx;
  let height = maxy - miny;
  // Replace previous canvas
  canvas.value = document.createElement("canvas");
  canvas.value.style.cssText = `
    position: absolute;
    left: 0;
    top: 0;
    border: 1px solid red;
    background-color: #fff;
  `;
  canvas.value.width = width;
  canvas.value.height = height;
  document.body.appendChild(canvas.value);
  // Replace previous drawing context
  ctx = canvas.value.getContext("2d");
  // Move the canvas origin to the center of the canvas
  ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
  // Restore the scrolling value to 0, because scrolling is not involved in the new canvas. How far away all elements are, we will create a canvas with how large it is
  scrollY = 0;
  // Render all elements
  allElements.forEach((element) => {
    // Why subtract minx and miny here? For example, the coordinates of the top left rectangle are (100100), so min and miny are calculated to be 100 and 100. When it is drawn on our new canvas, it should also be drawn to the top left, and the coordinates should be 0,0. Therefore, minx and miny need to be subtracted from all element coordinates
    element.x -= minx;
    element.y -= miny;
    element.render();
  });
};
copy

Of course, we have replaced the canvas elements, drawing context, etc., which should actually be restored to the original after export. It will not be expanded in detail due to limited space.

In vain

As we like the new and hate the old, it's time to say goodbye to our little rectangle.

Deleting is too simple. Just remove the rectangle from the element family array:

const deleteActiveElement = () => {
  if (!activeElement) {
    return;
  }
  let index = allElements.findIndex((element) => {
    return element === activeElement;
  });
  allElements.splice(index, 1);
  renderAllElements();
};
copy

Summary

The above is the core logic of the whiteboard. Is it very simple? If there is the next one, the author will continue to introduce the drawing of arrows, free writing, text drawing, and how to scale text and pictures according to scale. These graphics that need a fixed length width ratio, and how to scale free writing polylines, which are composed of multiple points. Please look forward to it, white~

Posted by kellydigital on Wed, 11 May 2022 19:05:11 +0300