置信各位写文章的敌人平时必定都有画图的需要,笔者平时用的是一个在线的手绘格调白板--excalidraw,应用体验上没的说,然而有一个问题,不能云端保留,不过好消息它是开源的,所以笔者就在想要不要基于它做一个反对云端保留的,于是三下两除二写了几个接口就实现了--小白板,尽管性能实现了,然而坏消息是excalidraw
是基于React
的,而且代码量很宏大,对于笔者这种长年写Vue
的人来说不是很敌对,另外也无奈在Vue
我的项目上应用,于是闲着也是闲着,笔者就花了差不多一个月的业余时间来做了一个粗率版的,框架无关,先来一睹为快:
也可体验在线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;};
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(); });}
祝贺矩形们胜利出世~
成长
修理它
小时候被爸妈修理,长大后换成被世界修理,从出世起,所有就都在变动之中,工夫会磨平你的棱角,也会减少你的体重,作为画布世界的操控者,当咱们想要修理一下某个矩形时要怎么做呢?第一步,选中它,第二步,修理它。
1.第一步,选中它
怎么在茫茫矩形海之中选中某个矩形呢,很简略,如果鼠标击中了某个矩形的边框则代表选中了它,矩形其实就是四根线段,所以只有判断鼠标是否点击到某根线段即可,那么问题就转换成了,怎么判断一个点是否和一根线段挨的很近,因为一根线很窄所以鼠标要精准点击到是很艰难的,所以咱们无妨认为鼠标的点击地位间隔指标10px
内都认为是击中的。
首先咱们能够依据点到直线的计算公式来判断一个点间隔一根直线的间隔:
点到直线的间隔公式为:
// 计算点到直线的间隔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)); }};
然而这样还不够,因为上面这种状况显然也满足条件然而不应该认为击中了线段:
因为直线是有限长的而线段不是,咱们还须要再判断一下点到线段的两个端点的间隔,这个点须要到两个端点的间隔都满足条件才行,下图是一个点间隔线段一个端点容许的最远的间隔:
计算两个点的间隔很简略,公式如下:
这样能够失去咱们最终的函数:
// 查看是否点击到了一条线段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("击中了矩形"); }};
能够看到尽管咱们胜利选中了矩形,然而却意外的又发明了一个新矩形,要防止这种状况咱们能够新增一个变量来辨别一下以后是发明矩形还是抉择矩形,在正确的时候做正确的事:
<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; }}
最初,选中一个矩形时为了能突出它被选中以及为了紧接着能修理它,咱们给它外围画个虚线框,并再增加上一些操作手柄,先给矩形模子减少一个属性,代表它被激活了:
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();};
能够看到激活新的矩形时并没有将之前的激活元素勾销掉,起因出在咱们的鼠标松开的处理函数,因为咱们之前的解决是鼠标松开时就把activeElement
复位成了null
,批改一下:
const onMouseup = (e) => { isMousedown = false; // 抉择模式下就不须要复位了 if (currentType.value !== 'selection') { activeElement = null; } mousedownX = 0; mousedownY = 0;};
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); } }};
当鼠标按住了矩形激活状态的某个区域并且鼠标开始挪动时即代表进行矩形修理操作,先来看按住了虚线框时的矩形挪动操作。
挪动矩形
挪动矩形很简略,批改它的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 = ""; }};
旋转矩形
先来批改一下矩形的模子,给它加上旋转的角度属性:
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; }}
接下来的问题就是如何计算鼠标挪动的角度了,即鼠标按下的地位到鼠标以后挪动到的地位通过的角度,两个点自身并不存在啥角度,只有绝对一个中心点会造成角度:
这个中心点其实就是矩形的中心点,上图夹角的计算能够依据这两个点与中心点组成的线段和程度x
轴造成的角度之差进行计算:
这两个夹角的正切值等于它们的对边除以邻边,对边和邻边咱们都能够计算出来,所以应用反正切函数即可计算出这两个角,最初再计算一下差值即可:
// 计算两个坐标以同一个中心点形成的角度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, };}
能够看到的确旋转了,然而显然不是咱们要的旋转,咱们要的是矩形以本身核心进行旋转,动图里显著不是,这其实是因为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; // ... }}
旋转后的问题
矩形旋转后会发现一个问题,咱们明明鼠标点击在进行的边框上,然而却无奈激活它,矩形想解脱咱们的管制?它想太多,起因其实很简略:
虚线是矩形没有旋转时的地位,咱们点击在了旋转后的边框上,然而咱们的点击检测是以矩形没有旋转时进行的,因为矩形尽管旋转了,然而实质上它的x、y
坐标并没有变,晓得了起因解决就很简略了,咱们无妨把鼠标指针的坐标以矩形核心为原点反向旋转矩形旋转的角度:
好了,问题又转化成了如何求一个坐标旋转指定角度后的坐标:
如上图所示,计算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; // ... }}
伸缩矩形
最初一种修理矩形的形式就是伸缩矩形,即调整矩形的大小,如下图所示:
虚线为伸缩前的矩形,实线为按住矩形右下角伸缩手柄拖动后的新矩形,矩形是由x、y、width、height
四个属性形成的,所以计算伸缩后的矩形,其实也就是计算出新矩形的x、y、width、height
,计算步骤如下(以下思路来自于https://github.com/shenhudong/snapping-demo/wiki/corner-handle。):
1.鼠标按下伸缩手柄后,计算出矩形这个角的对角点坐标diagonalPoint
:
2.依据鼠标以后挪动到的地位,再联合对角点diagonalPoint
能够计算出新矩形的中心点newCenter
:
3.新的中心点晓得了,那么咱们就能够把鼠标以后的坐标以新中心点反向旋转元素的角度,即可失去新矩形未旋转时的右下角坐标rp
:
4.中心点坐标有了,右下角坐标也有了,那么计算新矩形的x、y、wdith、height
都很简略了:
let width = (rp.x - newCenter.x) * 2let height = (rp.y- newCenter.y * 2let x = rp.x - widthlet 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; } // ...}
世界太小了
有一天咱们的小矩形说,世界这么大,它想去看看,的确,屏幕就这么大,矩形必定早就待腻了,作为万能的画布操控者,让咱们来满足它的要求。
咱们新增两个状态变量: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();};
小结
以上就是白板的外围逻辑,是不是很简略,如果有下一篇的话笔者会持续为大家介绍一下箭头的绘制、自在书写、文字的绘制,以及如何按比例缩放文字图片等这些须要固定长宽比例的图形、如何缩放自在书写折线这些由多个点形成的元素,敬请期待,白白~