世界太小了
有一天我们的小矩形说,世界这么大,它想去看看,确实,屏幕就这么大,矩形肯定早就待腻了,作为万能的画布操控者,让我们来满足它的要求。
我们新增两个状态变量: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); // ... } }
是不是很简单,但是问题又来了,因为滚动后会发现我们又无法激活矩形了,而且绘制矩形也出问题了:
原因和矩形旋转一样,滚动只是最终绘制的时候加上了滚动值,但是矩形的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
后的值。
距离产生美
有时候矩形太小了我们想近距离看看,有时候太大了我们又想离远一点,怎么办呢,很简单,加个放大缩小的功能!
新增一个变量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(); };
问题又又又来了朋友们,我们又无法激活矩形以及创造新矩形又出现偏移了:
还是老掉牙的原因,无论怎么滚动缩放旋转,矩形的x、y
本质都是不变的,没办法,转换吧:
同样是修改鼠标的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方法也是同样处理
能不能整齐一点
如果我们想让两个矩形对齐,靠手来操作是很难的,解决方法一般有两个,一是增加吸附的功能,二是通过网格,吸附功能是需要一定计算量的,本来咱们就不富裕的性能就更加雪上加霜了,所以咱们选择使用网格。
先来增加个画网格的方法:
// 渲染网格 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();// ++ });
到这里我们虽然绘制了网格,但是实际上没啥用,它并不能限制我们,我们需要绘制网格的时候让矩形贴着网格的边,这样绘制多个矩形的时候就能轻松的实现对齐了。
这个怎么做呢,很简单,因为网格也相当于是从左上角开始绘制的,所以我们获取到鼠标的clientX、clientY
后,对网格的大小进行取余,然后再减去这个余数,即可得到最近可以吸附到的网格坐标:
如上图所示,网格大小为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 }
当然,上述的代码还是有不足的,当我们滚动或缩小后,网格就没有铺满页面了:
解决起来也不难,比如上图,缩小以后,水平线没有延伸到两端,因为缩小后相当于宽度变小了,那我们只要绘制水平线时让宽度变大即可,那么可以除以缩放值:
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(); }); };
当然,我们替换了用来的画布元素、绘图上下文等,实际上应该在导出后恢复成原来的,篇幅有限就不具体展开了。
白白
作为喜新厌旧的我们,现在是时候跟我们的小矩形说再见了。
删除可太简单了,直接把矩形从元素大家庭数组里把它去掉即可:
const deleteActiveElement = () => { if (!activeElement) { return; } let index = allElements.findIndex((element) => { return element === activeElement; }); allElements.splice(index, 1); renderAllElements(); };
小结
以上就是白板的核心逻辑,是不是很简单,如果有下一篇的话笔者会继续为大家介绍一下箭头的绘制、自由书写、文字的绘制,以及如何按比例缩放文字图片等这些需要固定长宽比例的图形、如何缩放自由书写折线这些由多个点构成的元素,敬请期待,白白~