5
头图

Previous articleI made an online whiteboard! I will introduce you to the functions of drawing, selecting, dragging, rotating, scaling, zooming in and out, grid mode, exporting pictures, etc. of rectangles. This article continues to introduce the drawing of arrows, free writing, and text, and how to Proportional scaling of text pictures and other graphics that require a fixed aspect ratio, how to scale free writing polylines and other elements composed of multiple points.

drawing of arrows

An arrow is actually a line segment, but there are two small line segments at one end at a certain angle. Given the coordinates of the two endpoints, a line segment can be drawn. The key is how to calculate the coordinates of the other two small line segments, the arrow line segment and the line segment. We set the angle to 30度 and the length to 30px :

 let l = 30;
let deg = 30;

As shown in the figure, the coordinates of the two endpoints of the known line segment are: (x,y) , (tx,ty) , the two small line segments of the arrow have a head and line segment (tx,ty) Coincident, so we only (x1,y1) and (x2,y2) .

First look (x1,y1) :

Math.atan2函数计算出线段Aatan2(x, y)原点(0, 0)X轴正半轴的夹角大小, (x,y)原点,那么(tx,ty) The corresponding coordinates are (tx-x, ty-y) , then the angle A can be calculated as:

 let lineDeg = radToDeg(Math.atan2(ty - y, tx - x));// atan2计算出来为弧度,需要转成角度

Then the angle between the other side of the line segment and the X axis is also A :

It is known that the angle between the arrow line segment and the line segment is 30度 , then the arrow line segment sum X can be calculated by subtracting the two. The included angle of the axis B :

 let plusDeg = deg - lineDeg;

The arrow line segment is used as the hypotenuse, which can form a right triangle with the X axis, and then use the Pythagorean theorem to calculate the opposite side L2 and the adjacent side L1 :

 let l1 = l * Math.sin(degToRad(plusDeg));// 角度要先转成弧度
let l2 = l * Math.cos(degToRad(plusDeg));

最后, tx L2 x1的坐标, ty加上---de4419ce545b06a3b88bd1e4ac4fdf76 L1 The coordinates of y1 can be obtained:

 let _x = tx - l2
let _y = ty + l1

Calculating the (x2,y2) coordinates on the other side is similar. We can first calculate the angle between the axis and Y , and then use the Pythagorean theorem to calculate the opposite side and the adjacent side, and then use (tx,ty) Coordinate subtraction:

The angle B is:

 let plusDeg = 90 - lineDeg - deg;

(x2,y2) The coordinates are calculated as follows:

 let _x = tx - l * Math.sin(degToRad(plusDeg));// L1
let _y = ty - l * Math.cos(degToRad(plusDeg));// L2

free writing

Free writing is very simple, monitor the mouse movement event, record each point moved, and draw it with a line segment. We temporarily set the width of the line segment to 2px :

 const lastMousePos = {
    x: null,
    y: null
}
const onMouseMove = (e) => {
    if (lastMousePos.x !== null && lastMousePos.y !== null) {
        ctx.beginPath();
        ctx.lineWidth = 2;
        ctx.lineCap = "round";
        ctx.lineJoin = "round";
        ctx.moveTo(lastMousePos.x, lastMousePos.y);
        ctx.lineTo(e.clientX, e.clientY);
        ctx.stroke();
    }
    lastMousePos.x = e.clientX;
    lastMousePos.y = e.clientY;
}

The line segments drawn in this way are of the same thickness, which does not match the actual situation. Friends who have written calligraphy should have more experience. When the speed is slow, the lines drawn will be thicker, and the lines will be thinner when the speed is fast. A bit, so we can dynamically set the width of the line segment in combination with the speed.

First calculate the speed of the mouse at the current moment:

 let lastMouseTime = null;
const onMouseMove = (e) => {
    if (lastMousePos.x !== null && lastMousePos.y !== null) {
        // 使用两点距离公式计算出鼠标这一次和上一次的移动距离
        let mouseDistance = getTowPointDistance(
            e.clientX,
            e.clientY,
            lastMousePos.x,
            lastMousePos.y
        );
        // 计算时间
        let curTime = Date.now();
        let mouseDuration = curTime - lastMouseTime;
        // 计算速度
        let mouseSpeed = mouseDistance / mouseDuration;
        // 更新时间
        lastMouseTime = curTime;
    }
    // ...
}

Take a look at the calculated speed:

We take 10 as the maximum speed, 0.5 as the minimum speed, and also set a maximum and minimum width for the width of the line segment. , then when the speed is greater than the maximum speed, the width is set to the minimum width; less than the minimum speed, the width is set to the maximum width, and at the middle speed, the width is calculated proportionally:

 // 动态计算线宽
const computedLineWidthBySpeed = (speed) => {
    let lineWidth = 0;
    let minLineWidth = 2;
    let maxLineWidth = 4;
    let maxSpeed = 10;
    let minSpeed = 0.5;
    // 速度超快,那么直接使用最小的笔画
    if (speed >= maxSpeed) {
        lineWidth = minLineWidth;
    } else if (speed <= minSpeed) {
        // 速度超慢,那么直接使用最大的笔画
        lineWidth = maxLineWidth;
    } else {
        // 中间速度,那么根据速度的比例来计算
        lineWidth = maxLineWidth -
      ((speed - minSpeed) / (maxSpeed - minSpeed)) * maxLineWidth;
    }
    return lineWidth;
};

The proportional calculation of the intermediate speed is also very simple. Calculate the ratio of the current speed to the maximum speed and multiply it by the maximum width. Because the speed and width are inversely proportional, the width corresponding to the speed is calculated by subtracting the maximum width.

It can be seen that when the speed is slow, it is indeed wide, and when the speed is fast, it is indeed thin, but this width change is jumpy, very abrupt, and cannot reflect a gradual process. The solution is very simple, because it is relatively The gap is too large for the last line, so we can neutralize the width calculated this time with the width of the previous time, for example, half of each area is used as the width of this time:

 const computedLineWidthBySpeed = (speed, lastLineWidth = -1) => {
    // ...
    if (lastLineWidth === -1) {
        lastLineWidth = maxLineWidth;
    }
    // 最终的粗细为计算出来的一半加上上一次粗细的一半,防止两次粗细相差过大,出现明显突变
    return lineWidth * (1 / 2) + lastLineWidth * (1 / 2);
}

Although you can still see the mutation if you look closely, it is still much better than before.

drawing of text

The input of text is achieved through the input tag.

When drawing new text, create a borderless and backgroundless input element, display it at the position clicked by the mouse through fixed positioning, and then automatically acquire the focus, monitor input events, and calculate the size of the input text dynamically to update in real time The width and height of the text box can achieve the effect of continuous input. When the focus is lost, the text box is hidden, and the input text can be drawn through canvas .

When you click on a text to edit, you need to get the text and the corresponding style, such as font size, font, line height, color, etc., and then hide the text on the canvas canvas, and position the text box to In this position, set the text content, and also set the corresponding style, try to look like editing in place, instead of creating an additional input box for editing:

 // 显示文本编辑框
showTextEdit() {
    if (!this.editable) {
        // 输入框不存在,创建一个
        this.crateTextInputEl();
    } else {
        // 已创建则让它显示
        this.editable.style.display = "block";
    }
    // 更新文本框样式
    this.updateTextInputStyle();
    // 聚焦
    this.editable.focus();
}

// 创建文本输入框元素
crateTextInputEl() {
    this.editable = document.createElement("textarea");
    // 设置样式,让我们看不见
    Object.assign(this.editable.style, {
        position: "fixed",
        display: "block",
        minHeight: "1em",
        backfaceVisibility: "hidden",
        margin: 0,
        padding: 0,
        border: 0,
        outline: 0,
        resize: "none",
        background: "transparent",
        overflow: "hidden",
        whiteSpace: "pre",
    });
    // 监听事件
    this.editable.addEventListener("input", this.onTextInput);
    this.editable.addEventListener("blur", this.onTextBlur);
    // 插入到页面
    document.body.appendChild(this.editable);
}

Monitor the input through the input event, get the input text, calculate the width and height of the text, the text can wrap, so the overall width is the width of the longest line of text, and the width is calculated by creating a div The element inserts the text, sets the style, and then uses getBoundingClientRect to get the width of div , which is the width of the text:

 // 文本输入事件
onTextInput() {
    // 当前新建或编辑的文本元素
    let activeElement = this.app.elements.activeElement;
    // 实时更新文本
    activeElement.text = this.editable.value;
    // 计算文本的宽高
    let { width, height } = getTextElementSize(activeElement);
    // 更新文本元素的宽高
    activeElement.width = width;
    activeElement.height = height;
    // 根据当前文本元素更新输入框的样式
    this.updateTextInputStyle();
}

Update the information of text elements in real time for subsequent rendering through canvas , and then take a look at the implementation of getTextElementSize :

 // 计算一个文本元素的宽高
export const getTextElementSize = (element) => {
    let { text, style } = element;// 取出文字和样式数据
    let width = getWrapTextActWidth(element);// 获取文本的最大宽度
    const lines = Math.max(splitTextLines(text).length, 1);// 文本的行数
    let lineHeight = style.fontSize * style.lineHeightRatio;// 计算出行高
    let height = lines * lineHeight;// 行数乘行高计算出文本整体高度
    return {
        width,
        height,
    };
};

The width and height of the text are divided into two parts for calculation. The height is directly obtained by multiplying the number of lines and the height of the line. Let's take a look at the logic of calculating the width:

 // 计算换行文本的实际宽度
export const getWrapTextActWidth = (element) => {
    let { text } = element;
    let textArr = splitTextLines(text);// 将文字切割成行数组
    let maxWidth = -Infinity;
    // 遍历每行计算宽度
    textArr.forEach((textRow) => {
        // 计算某行文字的宽度
        let width = getTextActWidth(textRow, element.style);
        if (width > maxWidth) {
            maxWidth = width;
        }
    });
    return maxWidth;
};

// 文本切割成行
export const splitTextLines = (text) => {
    return text.replace(/\r\n?/g, "\n").split("\n");
};

// 计算文本的实际渲染宽度
let textCheckEl = null;
export const getTextActWidth = (text, style) => {
    if (!textCheckEl) {
        // 创建一个div元素
        textCheckEl = document.createElement("div");
        textCheckEl.style.position = "fixed";
        textCheckEl.style.left = "-99999px";
        document.body.appendChild(textCheckEl);
    }
    let { fontSize, fontFamily } = style;
    // 设置文本内容、字号、字体
    textCheckEl.innerText = text;
    textCheckEl.style.fontSize = fontSize + "px";
    textCheckEl.style.fontFamily = fontFamily;
    // 通过getBoundingClientRect获取div的宽度
    let { width } = textCheckEl.getBoundingClientRect();
    return width;
};

The width and height of the text are also calculated. Finally, let's take a look at the method of updating the text box:

 // 根据当前文字元素的样式更新文本输入框的样式
updateTextInputStyle() {
    let activeElement = this.app.elements.activeElement;
    let { x, y, width, height, style, text, rotate } = activeElement;
    // 设置文本内容
    this.editable.value = text;
    let styles = {
        font: getFontString(fontSize, style.fontFamily),// 设置字号及字体
        lineHeight: `${fontSize * style.lineHeightRatio}px`,// 设置行高
        left: `${x}px`,// 定位
        top: `${y}px`,
        color: style.fillStyle,// 设置颜色
        width: Math.max(width, 100) + "px",// 设置为文本的宽高
        height: height * state.scale + "px",
        transform: `rotate(${rotate}deg)`,// 文本元素旋转了,输入框也需要旋转
        opacity: style.globalAlpha,// 设置透明度
    };
    Object.assign(this.editable.style, styles);
}

// 拼接文字字体字号字符串
export const getFontString = (fontSize, fontFamily) => {
  return `${fontSize}px ${fontFamily}`;
};

Scale pictures and text

Both pictures and text belong to elements with a fixed width and height ratio, so the original ratio needs to be maintained when scaling. The scaling method introduced in the previous article cannot keep the ratio, so certain modifications are required. The previous one has passed. After such a long time, everyone must have forgotten the logic of expansion and contraction. You can review it first: 2. The second step, repair it (scroll down to the subsection [Retractable Rectangle]).

In summary, the drawing of a rectangle requires five attributes x,y,width,height,rotate and scaling will not affect the rotation, so calculating the stretched rectangle means calculating the new x,y,width,height value, which is also simply listed here. The following steps:

1. Calculate the coordinates of the diagonal point of the corner dragged by the mouse according to the center point of the rectangle. For example, if we drag the lower right corner of the rectangle, then the diagonal point is the upper left corner;

2. According to the real-time position dragged by the mouse and the coordinates of the diagonal points, calculate the coordinates of the center point of the new rectangle;

3. Obtain the real-time coordinates of the mouse after the rotation angle of the original rectangle is reversely rotated through the new center point;

4. Knowing the coordinates of the lower right corner when it is not rotated and the coordinates of the new center point, then the coordinates of the upper left corner, width and height of the new rectangle can be easily calculated;

Next, let's see how to scale up and down.

The black one is the original rectangle, and the green one is the rectangle that has been dragged in real time by pressing and holding the lower right corner of the mouse. This does not maintain the original aspect ratio. If you drag to this position, if you want to maintain the aspect ratio, it should be the rectangle shown in red.

According to the previous logic, we can calculate the position and width and height of the green rectangle before it is rotated, then the new ratio can also be calculated, and then according to the width and height ratio of the original rectangle, we can calculate the red rectangle before rotation. Position and width:

As shown in the figure, we first calculate the position, width and height of the green rectangle after real-time dragging when it is not rotated newRect , assuming that the aspect ratio of the original rectangle is 2 , the new rectangle The aspect ratio is 1 , the new one is smaller than the old one, then if you want the same ratio, you need to adjust the height of the new rectangle, and vice versa, adjust the width of the new rectangle, the calculation equation is:

 newRect.width / newRect.height = originRect.width / originRect.height

Then we can calculate the coordinates of the lower right corner of the red rectangle:

 let originRatio = originRect.width / originRect.height;// 原始矩形的宽高比
let newRatio = newRect.width / newRect.height;// 新矩形的宽高比
let x1, y1
if (newRatio < originRatio) {// 新矩形的比例小于原始矩形的比例,宽度不变,调整新矩形的高度
    x1 = newRect.x + newRect.width;
    y1 = newRect.y + newRect.width / originRatio;
} else if (newRatio > originRatio) {// 新矩形的比例大于原始矩形的比例,高度不变,调整新矩形的宽度
    x1 = newRect.x + originRatio * newRect.height;
    y1 = newRect.y + newRect.height;
}

The coordinates of the lower right corner of the red rectangle when it is not rotated are calculated, then we need to rotate it by the angle of the original rectangle with the new center point:

At this point, you will find that it seems familiar, yes, ignore the green rectangle, imagine that our mouse is dragged to the lower right corner of the red rectangle, then just repeat the 4 steps mentioned at the beginning again. The position and width and height of the red rectangle before it is rotated can be calculated, that is, the position, width and height of the proportionally scaled rectangle. For detailed code, please refer to: https://github.com/wanglin2/tiny_whiteboard/blob/main/tiny-whiteboard/src/elements/DragElement.js#L280 .

For pictures, the above steps are enough, because the size of the picture is the width and height, but for the text, its size is the font size, so we have to convert the calculated width and height into the font size, the author's approach is:

 新字号 = 新高度 / 行数 / 行高比例

code show as below:

 let fontSize = Math.floor(
    height / splitTextLines(text).length / style.lineHeightRatio
);
this.style.fontSize = fontSize;

For example, a piece of text has 2 line, the line height is 1.5 , and the calculated new height is 60 , then the font size calculated regardless of the line height 30 , considering the line height, obviously the font size will be smaller than 30 , x * 1.5 = 30 , so it needs to be divided by the line height ratio.

Scale polygons or polylines

Our scaling operation calculates the position, width and height of a new rectangle, which is the smallest bounding box for elements composed of multiple points (such as polygons, polylines, and freehand lines):

So we just need to be able to scale each point of the element according to the new width and height:

 // 更新元素包围框
updateRect(x, y, width, height) {
    let { startWidth, startHeight, startPointArr } = this;// 元素初始的包围框宽高、点位数组
    // 计算新宽高相对于原始宽高的缩放比例
    let scaleX = width / startWidth;
    let scaleY = height / startHeight;
    // 元素的所有点位都进行同步缩放
    this.pointArr = startPointArr.map((point) => {
        let nx = point[0] * scaleX;
        let ny = point[1] * scaleY;
        return [nx, ny];
    });
    // 更新元素包围框
    this.updatePos(x, y);
    this.updateSize(width, height);
    return this;
}

You can see that the element flew away. In fact, the zoomed size is correct. Let's drag the box over to compare:

So it is just a displacement. How does this displacement happen? Actually, it is very obvious. For example, the coordinates of two points of a line segment are (1,1) , (1,3) , zoom in 2 --After doubling, it becomes 2 (2,2) , (2,6) , obviously the line segment is enlarged and elongated, but it is also obvious that the position has changed:

The solution is that we can calculate the new bounding box of the element, and then calculate the distance from the original bounding box. Finally, all points after scaling can be offset back by this distance:

 // 更新元素包围框
updateRect(x, y, width, height) {
    // ...
    // 缩放后会发生偏移,所以计算一下元素的新包围框和原来包围框的差距,然后整体往回偏移
    let rect = getBoundingRect(this.pointArr);
    let offsetX = rect.x - x;
    let offsetY = rect.y - y;
    this.pointArr = this.pointArr.map((point) => {
        return [point[0] - offsetX, point[1] - offsetY, ...point.slice(2)];
    });
    this.updatePos(x, y);
    this.updateSize(width, height);
    return this;
}

Summarize

At this point, some core implementations of this small project have been introduced. Of course, this project still has many shortcomings, such as:

1. The click detection of an element is completely dependent on the distance from point to point or the distance from point to line, which results in that elements such as Bezier curves or ellipses are not supported, because the coordinates of each point on the curve cannot be known, nature cannot be compared;

2. There is no good solution for rotating multiple elements at the same time;

3. Mirror scaling is not supported;

Project address: https://github.com/wanglin2/tiny_whiteboard , welcome to give a star~


街角小林
883 声望771 粉丝