相信各位写文章的朋友平时肯定都有画图的需求,笔者平时用的是一个在线的手绘风格白板--excalidraw,使用体验上没的说,但是有一个问题,不能云端保存,不过好消息它是开源的,所以笔者就在想要不要基于它做一个支持云端保存的,于是三下两除二写了几个接口就完成了--小白板,虽然功能完成了,但是坏消息是excalidraw
是基于React
的,而且代码量很庞大,对于笔者这种常年写Vue
的人来说不是很友好,另外也无法在Vue
项目上使用,于是闲着也是闲着,笔者就花了差不多一个月的业余时间来做了一个草率版的,框架无关,先来一睹为快:
[图片上传失败...(image-fd4576-1651068854739)]
也可体验在线demo
:https://wanglin2.github.io/tiny_whiteboard_demo/。
源码仓库在此:https://github.com/wanglin2/tiny_whiteboard。
接下来笔者就来大致介绍一下实现的关键技术点。
本文的配图均使用笔者开发的白板进行绘制。
简单起见,我们以【一个矩形的一生】来看一下大致的整个流程实现。
出生
矩形即将出生的是一个叫做canvas
的画布世界,这个世界大致是这样的:
<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>
为什么要将画布世界的原点移动到中心呢,其实是为了方便后续的整体放大缩小。
矩形想要出生还缺了一样东西,事件,否则画布感受不到我们想要创造矩形的想法。
// ...
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();// ++
});
一个矩形想要在画布世界上存在,需要明确”有多大“和”在哪里“,多大即它的width、height
,哪里即它的x、y
。
当我们鼠标在画布世界按下时就决定了矩形出生的地方,所以我们需要记录一下这个位置:
let mousedownX = 0;
let mousedownY = 0;
let isMousedown = false;
const onMousedown = (e) => {
mousedownX = e.clientX;
mousedownY = e.clientY;
isMousedown = true;
};
当我们的鼠标不仅按下了,还开始在画布世界中移动的那一瞬间就会创造一个矩形了,其实我们可以创造无数个矩形,它们之间是有一些共同点的,就像我们男人一样,好男人坏男人都是两只眼睛一张嘴,区别只是有的人眼睛大一点,有的人比较会花言巧语而已,所以它们是存在模子的:
// 矩形元素类
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();
}
}
矩形创建完成后在我们的鼠标没有松开前都是可以修改它的初始大小的:
// 当前激活的元素
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();
};
当我们的鼠标松开后,矩形就正式出生了~
const onMouseup = (e) => {
isMousedown = false;
activeElement = null;
mousedownX = 0;
mousedownY = 0;
};
[图片上传失败...(image-bf07ee-1651068854739)]
what??和我们预想的不一样,首先我们的鼠标是在左上角移动,但是矩形却出生在中间位置,另外矩形大小变化的过程也显示出来了,而我们只需要看到最后一刻的大小即可。
其实我们鼠标是在另一个世界,这个世界的坐标原点在左上角,而前面我们把画布世界的原点移动到中心位置了,所以它们虽然是平行世界,但是奈何坐标系不一样,所以需要把我们鼠标的位置转换成画布的位置:
const screenToCanvas = (x, y) => {
return {
x: x - canvas.value.width / 2,
y: y - canvas.value.height / 2
}
}
然后在矩形渲染前先把坐标转一转:
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();
}
}
另一个问题是因为在画布世界中,你新画一些东西时,原来画的东西是依旧存在的,所以在每一次重新画所有元素前都需要先把画布清空一下:
const clearCanvas = () => {
let width = canvas.value.width;
let height = canvas.value.height;
ctx.clearRect(-width / 2, -height / 2, width, height);
};
在每次渲染矩形前先清空画布世界:
const renderAllElements = () => {
clearCanvas();// ++
allElements.forEach((element) => {
element.render();
});
}
[图片上传失败...(image-818f91-1651068854739)]
恭喜矩形们成功出生~
成长
修理它
小时候被爸妈修理,长大后换成被世界修理,从出生起,一切就都在变化之中,时间会磨平你的棱角,也会增加你的体重,作为画布世界的操控者,当我们想要修理一下某个矩形时要怎么做呢?第一步,选中它,第二步,修理它。
1.第一步,选中它
怎么在茫茫矩形海之中选中某个矩形呢,很简单,如果鼠标击中了某个矩形的边框则代表选中了它,矩形其实就是四根线段,所以只要判断鼠标是否点击到某根线段即可,那么问题就转换成了,怎么判断一个点是否和一根线段挨的很近,因为一根线很窄所以鼠标要精准点击到是很困难的,所以我们不妨认为鼠标的点击位置距离目标10px
内都认为是击中的。
首先我们可以根据点到直线的计算公式来判断一个点距离一根直线的距离:
[图片上传失败...(image-16bcbe-1651068854739)]
点到直线的距离公式为:
[图片上传失败...(image-6974d7-1651068854739)]
// 计算点到直线的距离
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));
}
};
但是这样还不够,因为下面这种情况显然也满足条件但是不应该认为击中了线段:
[图片上传失败...(image-8a09f5-1651068854739)]
因为直线是无限长的而线段不是,我们还需要再判断一下点到线段的两个端点的距离,这个点需要到两个端点的距离都满足条件才行,下图是一个点距离线段一个端点允许的最远的距离:
[图片上传失败...(image-1b9c9f-1651068854739)]
计算两个点的距离很简单,公式如下:
[图片上传失败...(image-22bbeb-1651068854739)]
这样可以得到我们最终的函数:
// 检查是否点击到了一条线段
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));
}
然后给我们矩形的模子加一个方法:
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;
}
}
现在我们可以来修改一下鼠标按下的函数,判断我们是否击中了一个矩形:
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("击中了矩形");
}
};
[图片上传失败...(image-fcd993-1651068854739)]
可以看到虽然我们成功选中了矩形,但是却意外的又创造了一个新矩形,要避免这种情况我们可以新增一个变量来区分一下当前是创造矩形还是选择矩形,在正确的时候做正确的事:
<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>
选择模式下可以选择矩形,但是不能创造新矩形,修改一下鼠标移动的方法:
const onMousemove = (e) => {
if (!isMousedown || currentType.value === 'selection') {
return;
}
}
[图片上传失败...(image-7f242f-1651068854739)]
最后,选中一个矩形时为了能突出它被选中以及为了紧接着能修理它,我们给它外围画个虚线框,并再添加上一些操作手柄,先给矩形模子增加一个属性,代表它被激活了:
class Rectangle {
constructor(opt) {
// ...
this.isActive = false;
}
}
然后再给它添加一个方法,当激活时渲染激活态图形:
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();
};
最后修改一下检测是否击中了元素的方法:
const checkIsHitElement = (x, y) => {
// ...
// 如果当前已经有激活元素则先将它取消激活
if (activeElement) {
activeElement.isActive = false;
}
// 更新当前激活元素
activeElement = hitElement;
if (hitElement) {
// 如果当前击中了元素,则将它的状态修改为激活状态
hitElement.isActive = true;
}
// 重新渲染所有元素
renderAllElements();
};
[图片上传失败...(image-780171-1651068854739)]
可以看到激活新的矩形时并没有将之前的激活元素取消掉,原因出在我们的鼠标松开的处理函数,因为我们之前的处理是鼠标松开时就把activeElement
复位成了null
,修改一下:
const onMouseup = (e) => {
isMousedown = false;
// 选择模式下就不需要复位了
if (currentType.value !== 'selection') {
activeElement = null;
}
mousedownX = 0;
mousedownY = 0;
};
[图片上传失败...(image-809549-1651068854739)]
2.第二步,修理它
终于到了万众瞩目的修理环节,不过别急,在修理之前我们还要做一件事,那就是得要知道我们鼠标具体在哪个操作手柄上,当我们激活一个矩形,它会显示激活态,然后再当我们按住了激活态的某个部位进行拖动时进行具体的修理操作,比如按住了中间的大虚线框里面则进行移动操作,按住了旋转手柄则进行矩形的旋转操作,按住了其他的四个角的操作手柄之一则进行矩形的大小调整操作。
具体的检测来说,中间的虚线框及四个角的调整手柄,都是判断一个点是否在矩形内,这个很简单:
// 判断一个坐标是否在一个矩形内
const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
};
旋转按钮是个圆,那么我们只要判断一个点到其圆心的距离,小于半径则代表在圆内,那么我们可以给矩形模子加上激活状态各个区域的检测方法:
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";
}
}
}
简单起见,四个角的操作手柄我们只演示右下角的一个,其他三个都是一样的,各位可以自行完善。
接下来又需要修改鼠标按下的方法,如果当前是选择模式,且已经有激活的矩形时,那么我们就判断是否按住了这个激活矩形的某个激活区域,如果确实按在了某个激活区域内,那么我们就设置两个标志位,记录当前是否处于矩形的调整状态中以及具体处在哪个区域,否则就进行原来的更新当前激活的矩形逻辑:
// 当前是否正在调整元素
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);
}
}
};
[图片上传失败...(image-66a51-1651068854739)]
当鼠标按住了矩形激活状态的某个区域并且鼠标开始移动时即代表进行矩形修理操作,先来看按住了虚线框时的矩形移动操作。
移动矩形
移动矩形很简单,修改它的x、y
即可,首先计算鼠标当前位置和鼠标按下时的位置之差,然后把这个差值加到鼠标按下时那一瞬间的矩形的x、y
上作为矩形新的坐标,那么这之前又得来修改一下咱们的矩形模子:
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;
}
}
啥时候保存矩形的状态呢,当然是鼠标按住了矩形激活状态的某个区域时:
const onMousedown = (e) => {
// ...
if (currentType.value === "selection") {
if (activeElement) {
if (hitActiveArea) {
// 按住了按住了激活状态的某个区域
isAdjustmentElement = true;
hitActiveElementArea = hitArea;
activeElement.save();// ++
}
}
// ...
}
}
然后当鼠标移动时就可以进行进行的移动操作了:
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;
}
// ...
}
不要忘记当鼠标松开时恢复标志位:
const onMouseup = (e) => {
// ...
if (isAdjustmentElement) {
isAdjustmentElement = false;
hitActiveElementArea = "";
}
};
[图片上传失败...(image-66f3f3-1651068854739)]
旋转矩形
先来修改一下矩形的模子,给它加上旋转的角度属性:
class Rectangle {
constructor(opt) {
// ...
// 旋转角度
this.rotate = opt.rotate || 0;
// 记录矩形的初始角度
this.startRotate = 0;
}
}
然后修改它的渲染方法:
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();// ++
}
}
画布的rotate
方法接收弧度为单位的值,我们保存角度值,所以需要把角度转成弧度,角度和弧度的互转公式如下:
因为360度=2PI
即180度=PI
所以:
1弧度=(180/π)°角度
1角度=π/180弧度
// 弧度转角度
const radToDeg = (rad) => {
return rad * (180 / Math.PI);
};
// 角度转弧度
const degToRad = (deg) => {
return deg * (Math.PI / 180);
};
然后和前面修改矩形的坐标套路一样,旋转时先保存初始角度,然后旋转时更新角度:
class Rectangle {
// 保存矩形此刻的状态
save() {
// ...
this.startRotate = this.rotate;
}
// 旋转矩形
rotateBy(or) {
this.rotate = this.startRotate + or;
}
}
接下来的问题就是如何计算鼠标移动的角度了,即鼠标按下的位置到鼠标当前移动到的位置经过的角度,两个点本身并不存在啥角度,只有相对一个中心点会形成角度:
[图片上传失败...(image-eb2e0b-1651068854739)]
这个中心点其实就是矩形的中心点,上图夹角的计算可以根据这两个点与中心点组成的线段和水平x
轴形成的角度之差进行计算:
[图片上传失败...(image-c8e0ce-1651068854739)]
这两个夹角的正切值等于它们的对边除以邻边,对边和邻边我们都可以计算出来,所以使用反正切函数即可计算出这两个角,最后再计算一下差值即可:
// 计算两个坐标以同一个中心点构成的角度
const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
// 计算出来的是弧度值,所以需要转成角度
return radToDeg(Math.atan2(fy - cy, fx - cx) - Math.atan2(ty - cy, tx - cx));
}
有了这个方法,接下来我们修改鼠标移动的函数:
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,
};
}
[图片上传失败...(image-1cc1f6-1651068854739)]
可以看到确实旋转了,但是显然不是我们要的旋转,我们要的是矩形以自身中心进行旋转,动图里明显不是,这其实是因为canvas
画布的rotate
方法是以画布原点为中心进行旋转的,所以绘制矩形时需要再移动一下画布原点,移动到自身的中心,然后再进行绘制,这样旋转就相当于以自身的中心进行旋转了,不过需要注意的是,原点变了,矩形本身和激活状态的相关图形的绘制坐标均需要修改一下:
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;
// ...
}
}
[图片上传失败...(image-e22b86-1651068854739)]
旋转后的问题
[图片上传失败...(image-9be49e-1651068854739)]
矩形旋转后会发现一个问题,我们明明鼠标点击在进行的边框上,但是却无法激活它,矩形想摆脱我们的控制?它想太多,原因其实很简单:
[图片上传失败...(image-c7952b-1651068854739)]
虚线是矩形没有旋转时的位置,我们点击在了旋转后的边框上,但是我们的点击检测是以矩形没有旋转时进行的,因为矩形虽然旋转了,但是本质上它的x、y
坐标并没有变,知道了原因解决就很简单了,我们不妨把鼠标指针的坐标以矩形中心为原点反向旋转矩形旋转的角度:
[图片上传失败...(image-83690f-1651068854739)]
好了,问题又转化成了如何求一个坐标旋转指定角度后的坐标:
[图片上传失败...(image-f785db-1651068854739)]
如上图所示,计算p1
以O
为中心逆时针旋转黑色角度后的p2
坐标,首先根据p1
的坐标计算绿色角度的反正切值,然后加上已知的旋转角度得到红色的角度,无论怎么旋转,这个点距离中心的点的距离都是不变的,所以我们可以计算出p1
到中心点O
的距离,也就是P2
到点O
的距离,斜边的长度知道了, 红色的角度也知道了,那么只要根据正余弦定理即可计算出对边和邻边的长度,自然p2
的坐标就知道了:
// 获取坐标经指定中心点旋转指定角度的坐标
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,
};
};
最后,修改一下矩形的点击检测方法:
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;
// ...
}
}
[图片上传失败...(image-1c33b8-1651068854739)]
伸缩矩形
最后一种修理矩形的方式就是伸缩矩形,即调整矩形的大小,如下图所示:
[图片上传失败...(image-e673f0-1651068854739)]
虚线为伸缩前的矩形,实线为按住矩形右下角伸缩手柄拖动后的新矩形,矩形是由x、y、width、height
四个属性构成的,所以计算伸缩后的矩形,其实也就是计算出新矩形的x、y、width、height
,计算步骤如下(以下思路来自于https://github.com/shenhudong/snapping-demo/wiki/corner-handle。):
1.鼠标按下伸缩手柄后,计算出矩形这个角的对角点坐标diagonalPoint
:
[图片上传失败...(image-7d5a0f-1651068854739)]
2.根据鼠标当前移动到的位置,再结合对角点diagonalPoint
可以计算出新矩形的中心点newCenter
:
[图片上传失败...(image-b47f48-1651068854739)]
3.新的中心点知道了,那么我们就可以把鼠标当前的坐标以新中心点反向旋转元素的角度,即可得到新矩形未旋转时的右下角坐标rp
:
[图片上传失败...(image-573df1-1651068854740)]
4.中心点坐标有了,右下角坐标也有了,那么计算新矩形的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
接下来看代码实现,首先修改一下矩形的模子,新增几个属性:
class Rectangle {
constructor(opt) {
// ...
// 对角点坐标
this.diagonalPoint = {
x: 0,
y: 0
}
// 鼠标按下位置和元素的角坐标的差值,因为我们是按住了拖拽手柄,这个按下的位置是和元素的角坐标存在一定距离的,所以为了不发生突变,需要记录一下这个差值
this.mousedownPosAndElementPosOffset = {
x: 0,
y: 0
}
}
}
然后修改一下矩形保存状态的save
方法:
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;
}
}
}
给save
方法增加了几个传参,所以也要相应修改一下鼠标按下的方法,在调用save
的时候传入鼠标当前的位置和按住了激活态的哪个区域。
接下来我们再给矩形的模子增加一个伸缩的方法:
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;
}
}
}
最后,让我们在鼠标移动函数里调用这个方法:
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;
}
// ...
}
[图片上传失败...(image-54af6b-1651068854740)]
世界太小了
有一天我们的小矩形说,世界这么大,它想去看看,确实,屏幕就这么大,矩形肯定早就待腻了,作为万能的画布操控者,让我们来满足它的要求。
我们新增两个状态变量:scrollX
、scrollY
,记录画布水平和垂直方向的滚动偏移量,以垂直方向的偏移量来介绍,当鼠标滚动时,增加或减少scrollY
,但是这个滚动值我们不直接应用到画布上,而是在绘制矩形的时候加上去,比如矩形用来的y
是100
,我们向上滚动了100px
,那么实际矩形绘制的时候的y=100-100=0
,这样就达到了矩形也跟着滚动的效果。
// 当前滚动值
let scrollY = 0;
// 监听事件
const bindEvent = () => {
// ...
canvas.value.addEventListener("mousewheel", onMousewheel);
};
// 鼠标移动事件
const onMousewheel = (e) => {
if (e.wheelDelta < 0) {
// 向下滚动
scrollY += 50;
} else {
// 向上滚动
scrollY -= 50;
}
// 重新渲染所有元素
renderAllElements();
};
然后我们再绘制矩形时加上这个滚动偏移量:
class Rectangle {
render() {
ctx.save();
let _x = this.x;
let _y = this.y - scrollY;
let canvasPos = screenToCanvas(_x, _y);
// ...
}
}
[图片上传失败...(image-3653a4-1651068854740)]
是不是很简单,但是问题又来了,因为滚动后会发现我们又无法激活矩形了,而且绘制矩形也出问题了:
[图片上传失败...(image-c7318b-1651068854740)]
原因和矩形旋转一样,滚动只是最终绘制的时候加上了滚动值,但是矩形的x、y
仍旧没有变化,因为绘制时是减去了scrollY
,那么我们获取到的鼠标的clientY
不妨加上scrollY
,这样刚好抵消了,修改一下鼠标按下和鼠标移动的函数:
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;
// ...
}
反正把之前所有使用e.clientY
的地方都修改成加上scrollY
后的值。
[图片上传失败...(image-e653c4-1651068854740)]
距离产生美
有时候矩形太小了我们想近距离看看,有时候太大了我们又想离远一点,怎么办呢,很简单,加个放大缩小的功能!
新增一个变量scale
:
// 当前缩放值
let scale = 1;
然后当我们绘制元素前缩放一下画布即可:
// 渲染所有元素
const renderAllElements = () => {
clearCanvas();
ctx.save();// ++
// 整体缩放
ctx.scale(scale, scale);// ++
allElements.forEach((element) => {
element.render();
});
ctx.restore();// ++
};
添加两个按钮,以及两个放大缩小的函数:
// 放大
const zoomIn = () => {
scale += 0.1;
renderAllElements();
};
// 缩小
const zoomOut = () => {
scale -= 0.1;
renderAllElements();
};
[图片上传失败...(image-64267a-1651068854740)]
问题又又又来了朋友们,我们又无法激活矩形以及创造新矩形又出现偏移了:
[图片上传失败...(image-7e5ea7-1651068854740)]
还是老掉牙的原因,无论怎么滚动缩放旋转,矩形的x、y
本质都是不变的,没办法,转换吧:
[图片上传失败...(image-e502ed-1651068854740)]
同样是修改鼠标的clientX、clientY
,先把鼠标坐标转成画布坐标,然后缩小画布的缩放值,最后再转成屏幕坐标即可:
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方法也是同样处理
[图片上传失败...(image-583bdb-1651068854740)]
能不能整齐一点
如果我们想让两个矩形对齐,靠手来操作是很难的,解决方法一般有两个,一是增加吸附的功能,二是通过网格,吸附功能是需要一定计算量的,本来咱们就不富裕的性能就更加雪上加霜了,所以咱们选择使用网格。
先来增加个画网格的方法:
// 渲染网格
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();
};
代码看着很多,但是逻辑很简单,就是从上往下扫描和从左往右扫描,然后在绘制元素前先绘制一些网格:
const renderAllElements = () => {
clearCanvas();
ctx.save();
ctx.scale(scale, scale);
renderGrid();// ++
allElements.forEach((element) => {
element.render();
});
ctx.restore();
};
进入页面就先调用一下这个方法即可显示网格:
onMounted(() => {
initCanvas();
bindEvent();
renderAllElements();// ++
});
[图片上传失败...(image-f0410e-1651068854740)]
到这里我们虽然绘制了网格,但是实际上没啥用,它并不能限制我们,我们需要绘制网格的时候让矩形贴着网格的边,这样绘制多个矩形的时候就能轻松的实现对齐了。
这个怎么做呢,很简单,因为网格也相当于是从左上角开始绘制的,所以我们获取到鼠标的clientX、clientY
后,对网格的大小进行取余,然后再减去这个余数,即可得到最近可以吸附到的网格坐标:
[图片上传失败...(image-5d3a9d-1651068854740)]
如上图所示,网格大小为20
,鼠标坐标是(65,65)
,x、y
都取余计算65%20=5
,然后均减去5
得到吸附到的坐标(60,60)
。
接下来修改onMousedown
和onMousemove
函数,需要注意的是这个吸附仅用于绘制图形,点击检测我们还是要使用未吸附的坐标:
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
}
[图片上传失败...(image-8ae55a-1651068854740)]
当然,上述的代码还是有不足的,当我们滚动或缩小后,网格就没有铺满页面了:
[图片上传失败...(image-ec0ede-1651068854740)]
解决起来也不难,比如上图,缩小以后,水平线没有延伸到两端,因为缩小后相当于宽度变小了,那我们只要绘制水平线时让宽度变大即可,那么可以除以缩放值:
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();
};
垂直线也是一样。
而当发生滚动后,比如向下滚动,那么上方的水平线没了,那我们只要补画一下上方的水平线,水平线我们是从-height/2
开始向下画到height/2
,那么我们就从-height/2
开始再向上补画:
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);
}
// ...
}
限于篇幅就不再展开,各位可以阅读源码或自行完善。
照个相吧
如果我们想记录某一时刻矩形的美要怎么做呢,简单,导出成图片就可以了。
导出图片不能简单的直接把画布导出就行了,因为当我们滚动或放大后,矩形也许都在画布外了,或者只有一个小矩形,而我们把整个画布都导出了也属实没有必要,我们可以先计算出所有矩形的公共外包围框,然后另外创建一个这么大的画布,把所有元素在这个画布里也绘制一份,然后再导出这个画布即可。
计算所有元素的外包围框可以先计算出每一个矩形的四个角的坐标,注意是要旋转之后的,然后再循环所有元素进行比较,计算出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;
}
};
代码很多,但是逻辑很简单,计算出了所有元素的外包围框信息,接下来就可以创建一个新画布以及把元素绘制上去:
// 导出为图片
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();
});
};
[图片上传失败...(image-37001c-1651068854740)]
当然,我们替换了用来的画布元素、绘图上下文等,实际上应该在导出后恢复成原来的,篇幅有限就不具体展开了。
白白
作为喜新厌旧的我们,现在是时候跟我们的小矩形说再见了。
删除可太简单了,直接把矩形从元素大家庭数组里把它去掉即可:
const deleteActiveElement = () => {
if (!activeElement) {
return;
}
let index = allElements.findIndex((element) => {
return element === activeElement;
});
allElements.splice(index, 1);
renderAllElements();
};
[图片上传失败...(image-8771a2-1651068854740)]
小结
以上就是白板的核心逻辑,是不是很简单,如果有下一篇的话笔者会继续为大家介绍一下箭头的绘制、自由书写、文字的绘制,以及如何按比例缩放文字图片等这些需要固定长宽比例的图形、如何缩放自由书写折线这些由多个点构成的元素,敬请期待,白白~