29
头图

I believe that all the friends who write articles must have the need to draw pictures. The author usually uses an online hand-painted style whiteboard-- excalidraw . I have nothing to say about the use experience, but there is a problem. It cannot be saved in the cloud, but the good news is that it is It is open source, so I was wondering whether to build a cloud-based storage support based on it, so I wrote a few interfaces in three clicks, two divided two, and finished it -- small whiteboard , although the function is completed, the bad news is excalidraw is based React , and the very large amount of code, write to the author of this perennial Vue people who are not very friendly, the other can not be in Vue Used in the project, so I am also idle. I spent almost a month in my spare time to make a sloppy version. The framework has nothing to do with it. Let's take a look at it first:

board.gif

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

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

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

The pictures in this article are drawn using the whiteboard developed by the author.

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

born

The rectangle is about to be born is a canvas world called canvas , the world 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");
        // 将画布的原点由左上角移动到中心点
        ctx.translate(width / 2, height / 2);
    };

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

Why move the origin of the canvas world to the center, in fact, to facilitate subsequent overall enlargement and reduction.

There is one thing missing for a rectangle to be born, an event, otherwise the canvas will not feel the idea of what we want to create a 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();// ++
});

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

When our mouse is pressed on the canvas world, the place where the rectangle is born is determined, 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;
};

When our mouse is not only pressed, but also starts to move in the canvas world, a rectangle will be created. In fact, we can create an infinite number of rectangles, and they have some things in common, just like us 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 eloquent, so they have a model:

 // 矩形元素类
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 until our mouse is released:

 // 当前激活的元素
let activeElement = null;
// 所有的元素
let allElements = [];
// 渲染所有元素
const renderAllElements = () => {
  allElements.forEach((element) => {
    element.render();
  });
}

const onMousemove = (e) => {
    if (!isMousedown) {
        return;
    }
    // 矩形不存在就先创建一个
    if (!activeElement) {
        activeElement = new Rectangle({
            x: mousedownX,
            y: mousedownY,
        });
        // 加入元素大家庭
        allElements.push(activeElement);
    }
    // 更新矩形的大小
    activeElement.width = e.clientX - mousedownX;
    activeElement.height = e.clientY - mousedownY;
    // 渲染所有的元素
    renderAllElements();
};

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

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

2022-04-25-15-40-29.gif

what? ? It is different from what we expected. First of all, our mouse is moved in the upper left corner, but the rectangle is born in the middle position. In addition, the process of changing the size of the rectangle is also displayed, and we only need to see the size at the last moment.

In fact, our mouse is in another world, the origin of the coordinates of this world is in the upper left corner, and we moved the origin of the canvas world to the center position, so although they are parallel worlds, but the coordinate systems are different, so we need to move our Convert the mouse position to the canvas position:

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

Then rotate the coordinates before rendering the rectangle:

 class Rectangle {
    constructor(opt) {}

    render() {
        ctx.beginPath();
        // 屏幕坐标转成画布坐标
        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 drawing still exists, so you need to clear the canvas before redrawing all elements each time:

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

Clear the canvas world before each rendering of the rectangle:

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

2022-04-25-15-41-13.gif

Congratulations to the rectangles for their successful birth~

growing up

fix it

When you were young, you were repaired by your parents, and when you grew up, you were repaired by the world. Since birth, everything has changed. Time will smooth out your edges and corners, and it will also increase your weight. As a manipulator of the canvas world, when What do we do when we want to fix a rectangle? The first step, select it, the second step, repair it.

1. The first step, select it

How to select a rectangle in the vast ocean of rectangles But, then the problem becomes, how to judge whether a point is very close to a line segment, because a line is very narrow, it is very difficult for the mouse to click accurately, so we might as well think that the click position of the mouse is far from the target. 10px both thought it was a hit.

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

image-20220425095139180.png

The formula for the distance from a point to a line is:

image-20220425100910804.png

 // 计算点到直线的距离
const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
  // 直线公式y=kx+b不适用于直线垂直于x轴的情况,所以对于直线垂直于x轴的情况单独处理
  if (x1 === x2) {
    return Math.abs(x - x1);
  } else {
    let k, b;
    // y1 = k * x1 + b  // 0式
    // b = y1 - k * x1  // 1式

    // y2 = k * x2 + b    // 2式
    // y2 = k * x2 + y1 - k * x1  // 1式代入2式
    // y2 - y1 = k * x2 - k * x1
    // y2 - y1 = k * (x2 -  x1)
    k = (y2 - y1) / (x2 -  x1) // 3式

    b = y1 - k * x1  // 3式代入0式
    
    return Math.abs((k * x - y + b) / Math.sqrt(1 + k * k));
  }
};

But this is not enough, because the following situation is obviously also satisfied but should not be considered to hit the line segment:

image-20220425101227980.png

Because the straight line is infinitely long and the line segment is not, we also 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 must meet the conditions. The following figure shows a point from one endpoint of the line segment. Maximum distance allowed:

image-20220425112504312.png

Calculating the distance between two points is simple, the formula is as follows:

image.png

This gives our final function:

 // 检查是否点击到了一条线段
const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
  // 点到直线的距离不满足直接返回
  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
    return false;
  }
  // 点到两个端点的距离
  let dis1 = getTowPointDistance(x, y, x1, y1);
  let dis2 = getTowPointDistance(x, y, x2, y2);
  // 线段两个端点的距离,也就是线段的长度
  let dis3 = getTowPointDistance(x1, y1, x2, y2);
  // 根据勾股定理计算斜边长度,也就是允许最远的距离
  let max = Math.sqrt(dis * dis + dis3 * dis3);
  // 点距离两个端点的距离都需要小于这个最远距离
  if (dis1 <= max && dis2 <= max) {
    return true;
  }
  return false;
};

// 计算两点之间的距离
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 model:

 class Rectangle {
    // 检测是否被击中
    isHit(x0, y0) {
        let { x, y, width, height } = this;
        // 矩形四条边的线段
        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 down function to determine if we hit a rectangle:

 const onMousedown = (e) => {
  // ...
  if (currentType.value === 'selection') {
    // 选择模式下进行元素激活检测
    checkIsHitElement(mousedownX, mousedownY);
  }
};

// 检测是否击中了某个元素
const checkIsHitElement = (x, y) => {
  let hitElement = null;
  // 从后往前遍历元素,即默认认为新的元素在更上层
  for (let i = allElements.length - 1; i >= 0; i--) {
    if (allElements[i].isHit(x, y)) {
      hitElement = allElements[i];
      break;
    }
  }
  if (hitElement) {
    alert("击中了矩形");
  }
};

2022-04-25-15-43-04.gif

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

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

<script setup>
// ...
// 当前操作模式
const currentType = ref('selection');
</script>

In selection mode, rectangles can be selected, but new rectangles cannot be created. Modify the method of mouse movement:

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

2022-04-25-15-44-43.gif

Finally, when a rectangle is selected, in order to highlight that it is selected and to repair it later, we draw a dotted frame around it, and add some operation handles. First, add an attribute to the rectangle mold, which means it is activated. :

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

Then add a method to it that renders the active state graphics when activated:

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

  // 当激活时渲染激活态
  renderActiveState() {
    if (!this.isActive) {
      return;
    }
    let canvasPos = screenToCanvas(this.x, this.y);
    // 为了不和矩形重叠,虚线框比矩形大一圈,增加5px的内边距
    let x = canvasPos.x - 5;
    let y = canvasPos.y - 5;
    let width = this.width + 10;
    let height = this.height + 10;
    // 主体的虚线框
    ctx.save();
    ctx.setLineDash([5]);
    drawRect(x, y, width, height);
    ctx.restore();
    // 左上角的操作手柄
    drawRect(x - 10, y - 10, 10, 10);
    // 右上角的操作手柄
    drawRect(x + width, y - 10, 10, 10);
    // 右下角的操作手柄
    drawRect(x + width, y + height, 10, 10);
    // 左下角的操作手柄
    drawRect(x - 10, y + height, 10, 10);
    // 旋转操作手柄
    drawCircle(x + width / 2, y - 10, 10);
  }
}

// 提取出公共的绘制矩形和圆的方法
// 绘制矩形
const drawRect = (x, y, width, height) => {
  ctx.beginPath();
  ctx.rect(x, y, width, height);
  ctx.stroke();
};
// 绘制圆形
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:

 const checkIsHitElement = (x, y) => {
  // ...
  // 如果当前已经有激活元素则先将它取消激活
  if (activeElement) {
    activeElement.isActive = false;
  }
  // 更新当前激活元素
  activeElement = hitElement;
  if (hitElement) {
    // 如果当前击中了元素,则将它的状态修改为激活状态
    hitElement.isActive = true;
  }
  // 重新渲染所有元素
  renderAllElements();
};

2022-04-25-15-36-09.gif

It can be seen that the previous activation element was not canceled when activating the new rectangle. The reason is the handler function of our mouse release, because our previous processing is to reset the activeElement when the mouse is released. It becomes null , modify it:

 const onMouseup = (e) => {
  isMousedown = false;
  // 选择模式下就不需要复位了
  if (currentType.value !== 'selection') {
    activeElement = null;
  }
  mousedownX = 0;
  mousedownY = 0;
};

2022-04-25-15-37-20.gif

2. The second step, repair it

It's finally time for the much-anticipated repair, but don't worry, we have to do one more thing before repairing, that is, we need to know which operating handle our mouse is on. When we activate a rectangle, it will show the activation state, and then Then, when we hold down a certain part of the active state to drag, we will perform specific repair operations. For example, if we hold down the large dotted line box in the middle, we will perform a moving operation. If we hold down the rotation handle, we will perform a rectangular rotation operation. Press One of the other four corners of the operation handles is used to adjust the size of the rectangle.

For specific detection, the dotted box in the middle and the adjustment handles at the four corners are all to determine whether a point is within the rectangle. This is very simple:

 // 判断一个坐标是否在一个矩形内
const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
};

The spin button is a circle, so we only need to judge the distance from a point to its center, and if it is less than the radius, it means it is inside the circle, then we can add detection methods for each area of the active state to the rectangular model:

 class Rectangle {
  // 检测是否击中了激活状态的某个区域
  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)) {
      // 在中间的虚线框
      return "body";
    } else if (getTowPointDistance(x0, y0, x + width / 2, y - 10) <= 10) {
      // 在旋转手柄
      return "rotate";
    } else if (checkPointIsInRectangle(x0, y0, x + width, y + height, 10, 10)) {
      // 在右下角操作手柄
      return "bottomRight";
    }
  }
}

For the sake of simplicity, we only demonstrate the one in the lower right corner of the operation handles at the four corners, and the other three are the same. You can improve it yourself.

Next, we need to modify the method of pressing the mouse. If it is currently in selection mode and there is already an activated rectangle, then we judge whether an activation area of the activation rectangle is pressed, and if it is indeed pressed on an activation In the area, then we set two flag bits to record whether it is currently in the adjustment state of the rectangle and which area it is in. Otherwise, the original logic of updating the currently activated rectangle is performed:

 // 当前是否正在调整元素
let isAdjustmentElement = false;
// 当前按住了激活元素激活态的哪个区域
let hitActiveElementArea = "";

const onMousedown = (e) => {
  mousedownX = e.clientX;
  mousedownY = e.clientY;
  isMousedown = true;
  if (currentType.value === "selection") {
    // 选择模式下进行元素激活检测
    if (activeElement) {
      // 当前存在激活元素则判断是否按住了激活状态的某个区域
      let hitActiveArea = activeElement.isHitActiveArea(mousedownX, mousedownY);
      if (hitActiveArea) {
        // 按住了按住了激活状态的某个区域
        isAdjustmentElement = true;
        hitActiveElementArea = hitArea;
        alert(hitActiveArea);
      } else {
        // 否则进行激活元素的更新操作
        checkIsHitElement(mousedownX, mousedownY);
      }
    } else {
      checkIsHitElement(mousedownX, mousedownY);
    }
  }
};

2022-04-25-15-34-01.gif

When the mouse presses a certain area of the rectangle activation state and the mouse starts to move, it means that the rectangle repair operation is performed. Let's first look at the rectangle movement operation when the dotted frame is pressed.

move rectangle

It is very simple to move the rectangle, modify its x、y , first calculate the difference between the current position of the mouse and the position when the mouse is pressed, and then add this difference to the rectangle at the moment when the mouse is pressed. x、y as the new coordinates of the rectangle, then we have to modify our rectangle model before this:

 class Rectangle {
  constructor(opt) {
    this.x = opt.x || 0;
    this.y = opt.y || 0;
    // 记录矩形的初始位置
    this.startX = 0;// ++
    this.startY = 0;// ++
    // ...
  }
    
  // 保存矩形某一刻的状态
  save() {
    this.startX = this.x;
    this.startY = this.y;
  }

  // 移动矩形
  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 on an area of the active state of the rectangle:

 const onMousedown = (e) => {
    // ...
    if (currentType.value === "selection") {
        if (activeElement) {
            if (hitActiveArea) {
                // 按住了按住了激活状态的某个区域
                isAdjustmentElement = true;
                hitActiveElementArea = hitArea;
                activeElement.save();// ++
            }
        }
        // ...
    }
}

Then when the mouse is moved, the movement operation can be performed:

 const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      // 调整元素中
      let ox = e.clientX - mousedownX;
      let oy = e.clientY - mousedownY;
      if (hitActiveElementArea === "body") {
        // 进行移动操作
        activeElement.moveBy(ox, oy);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

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

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

2022-04-25-17-11-54.gif

Rotated rectangle

First, let's modify the rectangle model and add the rotation angle attribute to it:

 class Rectangle {
    constructor(opt) {
        // ...
        // 旋转角度
        this.rotate = opt.rotate || 0;
        // 记录矩形的初始角度
        this.startRotate = 0;
    }
}

Then modify its render 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();// ++
    }
}

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:

 因为360度=2PI
即180度=PI
所以:

1弧度=(180/π)°角度
1角度=π/180弧度
 // 弧度转角度
const radToDeg = (rad) => {
  return rad * (180 / Math.PI);
};

// 角度转弧度
const degToRad = (deg) => {
  return deg * (Math.PI / 180);
};

Then, like the previous coordinate routine for modifying the rectangle, first save the initial angle when rotating, and then update the angle when rotating:

 class Rectangle {
    // 保存矩形此刻的状态
    save() {
        // ...
        this.startRotate = this.rotate;
    }

    // 旋转矩形
    rotateBy(or) {
        this.rotate = this.startRotate + or;
    }
}

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

image-20220425181312806.png

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

image-20220425181845910.png

The tangent value of these two angles is equal to their opposite side divided by the adjacent side, we can calculate both the opposite side and the adjacent side, so use the arctangent function to calculate these two angles, and finally calculate the difference Can:

 // 计算两个坐标以同一个中心点构成的角度
const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
  // 计算出来的是弧度值,所以需要转成角度
  return radToDeg(Math.atan2(fy - cy, fx - cx) - Math.atan2(ty - cy, tx - cx));
}

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

 const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 进行移动操作
      } else if (hitActiveElementArea === 'rotate') {
        // 进行旋转操作
        // 矩形的中心点
        let center = getRectangleCenter(activeElement);
        // 获取鼠标移动的角度
        let or = getTowPointRotate(center.x, center.y, mousedownX, mousedownY, e.clientX, e.clientY);
        activeElement.rotateBy(or);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

// 计算矩形的中心点
const getRectangleCenter = ({x, y, width, height}) => {
  return {
    x: x + width / 2,
    y: y + height / 2,
  };
}

2022-04-25-18-40-49.gif

It can be seen that it is indeed rotated, but it is obviously not the rotation we want. What we want is that the rectangle rotates at its own center, which is obviously not in the animation. This is actually because canvas canvas rotate method is to rotate the origin of the canvas as the center, so when drawing the rectangle, you need to move the origin of the canvas, move it to the center of itself, and then draw, so that the rotation is equivalent to rotating at the center of itself, but it needs to be It should be noted that the origin has changed, and the drawing coordinates of the rectangle itself and the related graphics in the active state need to be modified:

 class Rectangle {
    render() {
        ctx.save();
        let canvasPos = screenToCanvas(this.x, this.y);
        // 将画布原点移动到自身的中心
        let halfWidth = this.width / 2
        let halfHeight = this.height / 2
        ctx.translate(canvasPos.x + halfWidth, canvasPos.y + halfHeight);
        // 旋转
        ctx.rotate(degToRad(this.rotate));
        // 原点变成自身中心,那么自身的坐标x,y也需要转换一下,即:canvasPos.x - (canvasPos.x + halfWidth),其实就变成了(-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;
        // ...
    }
}

2022-04-25-19-08-00.gif

problem after rotation

2022-04-25-19-10-40.gif

After the rectangle is rotated, we will find a problem. We clearly click on the border in progress, but we cannot activate it. The rectangle wants to get rid of our control? It thinks too much, and the reason is actually very simple:

image-20220425192046034.png

The dotted line is the position when the rectangle is not rotated. We click on the rotated frame, but our click detection is performed when the rectangle is not rotated, because although the rectangle is rotated, its essence is x、y The coordinates have not changed. It is very simple to know the reason. We might as well rotate the coordinates of the mouse pointer with the center of the rectangle as the origin and reverse the rotation angle of the rectangle:

image-20220425192752165.png

Well, the problem has been transformed into how to find the coordinates after a coordinate is rotated by a specified angle:

image-20220425200034610.png

As shown in the figure above, calculate p1 with O as the center and rotate the black angle counterclockwise p2 coordinates, first according to the coordinates of p1 Calculate the arc tangent of the green angle, and then add the known rotation angle to get the red angle. No matter how you rotate, the distance between this point and the center point is the same, so we can calculate p1 distance to the center point O , that is, the distance from P2 to the point O , the length of the hypotenuse is known, and the angle of red is known, then As long as the length of the opposite side and the adjacent side can be calculated according to the law of sine and cosine, the coordinates of the natural p2 will be known:

 // 获取坐标经指定中心点旋转指定角度的坐标
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 click detection method of the rectangle:

 class Rectangle {
    // 检测是否被击中
    isHit(x0, y0) {
        // 反向旋转矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }

    // 检测是否击中了激活状态的某个区域
    isHitActiveArea(x0, y0) {
        // 反向旋转矩形的角度
        let center = getRectangleCenter(this);
        let rotatePoint = getRotatedPoint(x0, y0, center.x, center.y, -this.rotate);
        x0 = rotatePoint.x;
        y0 = rotatePoint.y;
        // ...
    }
}

2022-04-25-20-19-44.gif

telescopic rectangle

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

image-20220426094039264.png

The dotted line is the rectangle before scaling, and the solid line is the new rectangle after dragging by pressing the scaling handle at the lower right corner of the rectangle. The rectangle is composed of four attributes x、y、width、height , so the calculation of the stretched rectangle is actually Calculate the x、y、width、height of the new rectangle. The calculation steps are as follows (the following ideas come from https://github.com/shenhudong/snapping-demo/wiki/corner-handle .):

1. After the mouse presses the telescopic handle, calculate the coordinates of the diagonal point of the corner of the rectangle diagonalPoint :

image-20220426095731343.png

2. According to the current position of the mouse, combined with the diagonal points diagonalPoint , the center point of the new rectangle can be calculated newCenter :

image-20220426100228212.png

3. Knowing the new center point, then we can rotate the current coordinates of the mouse by the angle of the element in the reverse direction of the new center point to get the coordinates of the lower right corner of the new rectangle when it is not rotated rp :

image-20220426100551601.png

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

 let 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 properties:

 class Rectangle {
    constructor(opt) {
        // ...
        // 对角点坐标
        this.diagonalPoint = {
            x: 0,
            y: 0
        }
        // 鼠标按下位置和元素的角坐标的差值,因为我们是按住了拖拽手柄,这个按下的位置是和元素的角坐标存在一定距离的,所以为了不发生突变,需要记录一下这个差值
        this.mousedownPosAndElementPosOffset = {
            x: 0,
            y: 0
        }
    }
}

Then modify the save method of saving the state of the rectangle:

 class Rectangle {
  // 保存矩形此刻的状态
  save(clientX, clientY, hitArea) {// 增加几个入参
    // ...
    if (hitArea === "bottomRight") {
      // 矩形的中心点坐标
      let centerPos = getRectangleCenter(this);
      // 矩形右下角的坐标
      let pos = {
        x: this.x + this.width,
        y: this.y + this.height,
      };
      // 如果元素旋转了,那么右下角坐标也要相应的旋转
      let rotatedPos = getRotatedPoint(pos.x, pos.y, centerPos.x, centerPos.y, this.rotate);
      // 计算对角点的坐标
      this.diagonalPoint.x = 2 * centerPos.x - rotatedPos.x;
      this.diagonalPoint.y = 2 * centerPos.y - rotatedPos.y;
      // 计算鼠标按下位置和元素的左上角坐标差值
      this.mousedownPosAndElementPosOffset.x = clientX - rotatedPos.x;
      this.mousedownPosAndElementPosOffset.y = clientY - rotatedPos.y;
    }
  }
}

Several parameters have been added to the save method, so the method of pressing the mouse should be modified accordingly. When calling save , pass in the current position of the mouse and press and hold it to activate which region of the state.

Next, we add a telescopic method to the rectangular mold:

 class Rectangle {
  // 伸缩
  stretch(clientX, clientY, hitArea) {
    // 鼠标当前的坐标减去偏移量得到矩形这个角的坐标
    let actClientX = clientX - this.mousedownPosAndElementPosOffset.x;
    let actClientY = clientY - this.mousedownPosAndElementPosOffset.y;
    // 新的中心点
    let newCenter = {
      x: (actClientX + this.diagonalPoint.x) / 2,
      y: (actClientY + this.diagonalPoint.y) / 2,
    };
    // 获取新的角坐标经新的中心点反向旋转元素的角度后的坐标,得到矩形未旋转前的这个角坐标
    let rp = getRotatedPoint(
      actClientX,
      actClientY,
      newCenter.x,
      newCenter.y,
      -this.rotate
    );
    if (hitArea === "bottomRight") {
      // 计算新的大小
      this.width = (rp.x - newCenter.x) * 2;
      this.height = (rp.y - newCenter.y) * 2;
      // 计算新的位置
      this.x = rp.x - this.width;
      this.y = rp.y - this.height;
    }
  }
}

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

 const onMousemove = (e) => {
  if (!isMousedown) {
    return;
  }
  if (currentType.value === "selection") {
    if (isAdjustmentElement) {
      if (hitActiveElementArea === "body") {
        // 进行移动操作
      } else if (hitActiveElementArea === 'rotate') {
        // 进行旋转操作
      } else if (hitActiveElementArea === 'bottomRight') {
        // 进行伸缩操作
        activeElement.stretch(e.clientX, e.clientY, hitActiveElementArea);
      }
      renderAllElements();
    }
    return;
  }
  // ...
}

2022-04-26-15-22-47.gif

The world is too small

One day our little rectangle said that the world is so big, and it wants to see it. Indeed, the screen is so big, and the rectangle must have been tired of it. As an all-purpose canvas manipulator, let us meet its requirements.

We add two new state variables: scrollX , scrollY , record the scroll offset in the horizontal and vertical directions of the canvas, introduced by the offset in the vertical direction, when the mouse scrolls , increase or decrease scrollY , but we do not apply this scroll value directly to the canvas, but add it when drawing the rectangle, such as the rectangle is used for y Yes 100 , we scroll up 100px , then when the actual rectangle is drawn y=100-100=0 , so the effect of the rectangle is also scrolled.

 // 当前滚动值
let scrollY = 0;

// 监听事件
const bindEvent = () => {
  // ...
  canvas.value.addEventListener("mousewheel", onMousewheel);
};

// 鼠标移动事件
const onMousewheel = (e) => {
  if (e.wheelDelta < 0) {
    // 向下滚动
    scrollY += 50;
  } else {
    // 向上滚动
    scrollY -= 50;
  }
  // 重新渲染所有元素
  renderAllElements();
};

Then we add this scroll offset when we draw the rectangle:

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

2022-04-26-16-06-53.gif

Is 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 with drawing the rectangle:

2022-04-26-16-11-26.gif

The reason is the same as the rotation of the rectangle. The scroll value is only added to the final drawing, but the rectangle x、y still does not change, because the drawing is subtracted scrollY , then we get To the mouse clientY may wish to add scrollY , which just offsets it, modify the function 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") {
                // 进行移动操作
            } else if (hitActiveElementArea === "rotate") {
                // ...
                let or = getTowPointRotate(
                  center.x,
                  center.y,
                  mousedownX,
                  mousedownY,
                  _clientX,
                  _clientY
                );
                // ...
            }
        }
    }
    // ...
    // 更新矩形的大小
      activeElement.width = _clientX - mousedownX;
      activeElement.height = _clientY - mousedownY;
    // ...
}

Anyway, all the places where e.clientY were used before are modified to the value after adding scrollY .

2022-04-26-16-18-21.gif

Distance produces beauty

Sometimes the rectangle is too small and we want to take a close look, and sometimes it is too big and we want to stay away. What should we do? It's very simple.

Add a new variable scale :

 // 当前缩放值
let scale = 1;

Then we scale the canvas before we draw the element:

 // 渲染所有元素
const renderAllElements = () => {
  clearCanvas();
  ctx.save();// ++
  // 整体缩放
  ctx.scale(scale, scale);// ++
  allElements.forEach((element) => {
    element.render();
  });
  ctx.restore();// ++
};

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

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

// 缩小
const zoomOut = () => {
  scale -= 0.1;
  renderAllElements();
};

2022-04-26-16-44-38.gif

The problem comes again friends, we can't activate the rectangle and create a new rectangle and the offset occurs:

2022-04-26-16-50-02.gif

It’s still an old-fashioned reason. No matter how you scroll, zoom, and rotate, the essence of the rectangle— x、y is the same, there is no way, convert it:

image-20220426170111431.png

The same is to modify the mouse clientX、clientY , first convert the mouse coordinates to canvas coordinates, then reduce the zoom value of the canvas, and finally convert to screen coordinates:

 const onMousedown = (e) => {
  // 处理缩放
  let canvasClient = screenToCanvas(e.clientX, e.clientY);// 屏幕坐标转成画布坐标
  let _clientX = canvasClient.x / scale;// 缩小画布的缩放值
  let _clientY = canvasClient.y / scale;
  let screenClient = canvasToScreen(_clientX, _clientY)// 画布坐标转回屏幕坐标
  // 处理滚动
  _clientX = screenClient.x;
  _clientY = screenClient.y + scrollY;
  mousedownX = _clientX;
  mousedownY = _clientY;
  // ...
}
// onMousemove方法也是同样处理

2022-04-26-17-10-04.gif

can you be neater

If we want to align 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 to use the grid. The adsorption function requires a certain amount of calculation. The rich performance is even worse, so we choose to use the grid.

First, let's add a method to draw a grid:

 // 渲染网格
const renderGrid = () => {
  ctx.save();
  ctx.strokeStyle = "#dfe0e1";
  let width = canvas.value.width;
  let height = canvas.value.height;
  // 水平线,从上往下画
  for (let i = -height / 2; i < height / 2; i += 20) {
    drawHorizontalLine(i);
  }
  // 垂直线,从左往右画
  for (let i = -width / 2; i < width / 2; i += 20) {
    drawVerticalLine(i);
  }
  ctx.restore();
};
// 绘制网格水平线
const drawHorizontalLine = (i) => {
  let width = canvas.value.width;
  // 不要忘了绘制网格也需要减去滚动值
  let _i = i - scrollY;
  ctx.beginPath();
  ctx.moveTo(-width / 2, _i);
  ctx.lineTo(width / 2, _i);
  ctx.stroke();
};
// 绘制网格垂直线
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, it scans from top to bottom and from left to right, and then draws some grids before drawing elements:

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

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

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

image-20220426184526124.png

Although we have drawn the grid here, it is actually useless. It does not limit us. We need to make the rectangle stick to the edge of the grid when drawing the grid, so that it can be easily realized when drawing multiple rectangles. aligned.

How to do this is very simple, because the grid is also drawn from the upper left corner, so we get the mouse clientX、clientY , take the remainder of the size of the grid, and then subtract this The remainder, you can get the nearest grid coordinates that can be snapped to:

image-20220426185905438.png

As shown above, the mesh size 20 , mouse coordinates (65,65) , x、y are calculated 65%20=5 , are then reduced Go to 5 to get the coordinates of the adsorption (60,60) .

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

 const onMousedown = (e) => {
    // 处理缩放
    // ...
    // 处理滚动
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // 吸附到网格
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    mousedownX = gridClientX;// 改用吸附到网格的坐标
    mousedownY = gridClientY;
    // ...
    // 后面进行元素检测的坐标我们还是使用_clientX、_clientY,保存矩形当前状态的坐标需要换成使用gridClientX、gridClientY
    activeElement.save(gridClientX, gridClientY, hitArea);
    // ...
}

const onMousemove = (e) => {
    // 处理缩放
    // ...
    // 处理滚动
    _clientX = screenClient.x;
    _clientY = screenClient.y + scrollY;
    // 吸附到网格
    let gridClientX = _clientX - _clientX % 20;
    let gridClientY = _clientY - _clientY % 20;
    // 后面所有的坐标都由_clientX、_clientY改成使用gridClientX、gridClientY
}

2022-04-26-19-40-51.gif

Of course, the above code still has shortcomings. When we scroll or zoom out, the grid does not cover the page:

2022-04-26-20-09-36.gif

It is not difficult to solve. For example, in the above picture, after zooming out, the horizontal line does not extend to both ends, because the width becomes smaller after zooming out, so we only need to make the width larger when drawing the horizontal line, then we can divide it by the zoom 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();
};

The same goes for vertical lines.

And when scrolling occurs, such as scrolling down, then the upper horizontal line is gone, then we just need to redraw the upper horizontal line. We draw the horizontal line from -height/2 down to height/2 , then we start from -height/2 and then make up the painting:

 const renderGrid = () => {
    // ...
    // 水平线
    for (let i = -height / 2; i < height / 2; i += 20) {
        drawHorizontalLine(i);
    }
    // 向下滚时绘制上方超出部分的水平线
    for (
        let i = -height / 2 - 20;
        i > -height / 2 + scrollY;
        i -= 20
    ) {
        drawHorizontalLine(i);
    }
    // ...
}

Due to space limitations, it will not be expanded. 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 moment, it is simple, just export it as 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 is only a small rectangle, and it is not necessary for us to export the entire canvas, we can first Calculate the common outer bounding box of all rectangles, and then create another such large canvas, draw all elements in this canvas, and 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, pay attention to the rotation, and then loop through all the elements for comparison, and calculate minx、maxx、miny、maxy .

 // 获取多个元素的最外层包围框信息
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,
  };
}
// 获取元素的四个角的坐标,应用了旋转之后的
const getElementCorners = (element) => {
  // 左上角
  let topLeft = getElementRotatedCornerPoint(element, "topLeft")
  // 右上角
  let topRight = getElementRotatedCornerPoint(element, "topRight");
  // 左下角
  let bottomLeft = getElementRotatedCornerPoint(element, "bottomLeft");
  // 右下角
  let bottomRight = getElementRotatedCornerPoint(element, "bottomRight");
  return [topLeft, topRight, bottomLeft, bottomRight];
}
// 获取元素旋转后的四个角坐标
const getElementRotatedCornerPoint = (element, dir) => {
  // 元素中心点
  let center = getRectangleCenter(element);
  // 元素的某个角坐标
  let dirPos = getElementCornerPoint(element, dir);
  // 旋转元素的角度
  return getRotatedPoint(
    dirPos.x,
    dirPos.y,
    center.x,
    center.y,
    element.rotate
  );
};
// 获取元素的四个角坐标
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 is a lot of code, but the logic is very simple. After calculating the outer bounding box information of all elements, you can create a new canvas and draw the elements:

 // 导出为图片
const exportImg = () => {
  // 计算所有元素的外包围框信息
  let { minx, maxx, miny, maxy } = getMultiElementRectInfo(allElements);
  let width = maxx - minx;
  let height = maxy - miny;
  // 替换之前的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);
  // 替换之前的绘图上下文
  ctx = canvas.value.getContext("2d");
  // 画布原点移动到画布中心
  ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
  // 将滚动值恢复成0,因为在新画布上并不涉及到滚动,所有元素距离有多远我们就会创建一个有多大的画布
  scrollY = 0;
  // 渲染所有元素
  allElements.forEach((element) => {
    // 这里为什么要减去minx、miny呢,因为比如最左上角矩形的坐标为(100,100),所以min、miny计算出来就是100、100,而它在我们的新画布上绘制时应该刚好也是要绘制到左上角的,坐标应该为0,0才对,所以所有的元素坐标均需要减去minx、miny
    element.x -= minx;
    element.y -= miny;
    element.render();
  });
};

2022-04-27-09-58-18.gif

Of course, we replaced the canvas elements, drawing contexts, etc. that were used for it. In fact, it should be restored to the original after exporting, so we will not expand it in detail due to the limited space.

in vain

As us who love the new and hate the old, it's time to say goodbye to our little rectangle.

Deleting is too easy, 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();
};

2022-04-27-10-04-06.gif

summary

The above is the core logic of the whiteboard. Is it very simple? If there is a next article, the author will continue to introduce the drawing of arrows, free writing, drawing of text, and how to scale text and pictures proportionally. These require a fixed aspect ratio Graphics, how to scale free writing polylines, these elements composed of multiple points, please look forward to it, Baibai~


街角小林
886 声望773 粉丝