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:

copy<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>

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.

copy// ... 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();// ++ });

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:

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

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:

copy// 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(); } }

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

copy// 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(); };

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

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

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:

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

Then turn the coordinates before rendering the rectangle:

copyclass 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(); } }

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:

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

Empty the canvas world before rendering the rectangle each time:

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

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:

copy// 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)); } };

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:

copy// 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)); }

Then add a method to our rectangular mold:

copyclass 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; } }

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

copyconst 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"); } };

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:

copy<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>

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

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

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:

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

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

copyclass 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(); };

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

copyconst 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(); };

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:

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

#### 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:

copy// 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; };

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:

copyclass 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"; } } }

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:

copy// 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); } } };

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:

copyclass 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; } }

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

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

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

copyconst 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; } // ... }

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

copyconst onMouseup = (e) => { // ... if (isAdjustmentElement) { isAdjustmentElement = false; hitActiveElementArea = ""; } };

##### Rotate rectangle

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

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

Then modify its rendering method:

copyclass 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();// ++ } }

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:

copyBecause 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); };

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:

copyclass Rectangle { // Save the current state of the rectangle save() { // ... this.startRotate = this.rotate; } // Rotate rectangle rotateBy(or) { this.rotate = this.startRotate + or; } }

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:

copy// 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)); }

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

copyconst 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, }; }

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:

copyclass 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; // ... } }

##### 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:

copy// 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, }; };

Finally, modify the rectangle click detection method:

copyclass 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; // ... } }

##### 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:

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

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

copyclass 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 } } }

Then modify the save method of rectangle save status:

copyclass 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; } } }

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:

copyclass 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; } } }

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

copyconst 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; } // ... }

### 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.

copy// 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(); };

Then we add this scroll offset when drawing the rectangle:

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

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:

copyconst 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; // ... }

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:

copy// Current zoom value let scale = 1;

Then we can zoom the canvas before drawing elements:

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

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

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

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:

copyconst 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

### 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:

copy// 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(); };

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:

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

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

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

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:

copyconst 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 }

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:

copyconst 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(); };

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:

copyconst 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); } // ... }

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.

copy// 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; } };

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:

copy// 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(); }); };

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:

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

## 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~