⚠️同样的,注释写的很详细,上述代码用到的canvas API除了之前介绍的外,用到的新的API如下:globalCompositeOperation、drawImage
实现截图工具栏
我们实现镂空选区的相关功能后,接下来要做的就是在选区内进行圈选、框选、画线等操作了,在QQ的截图中这些操作位于截图工具栏内,因此我们要将截图工具栏做出来,做到与canvas交互。
在截图工具栏的布局上,一开始我的想法是直接在canvas画布中把这些工具画出来,这样应该更容易交互一点,但是我看了相关的api后,发现有点麻烦,把问题复杂化了。
琢磨了一阵后,想明白了,这块还是需要使用div进行布局的,在裁剪框绘制完毕后,根据裁剪框的位置信息计算出截图工具栏的位置,改变其位置即可。
工具栏与canvas的交互,可以绑定一个点击事件到EventMonitoring.ts
中,获取当前点击项,指定与之对应的图形绘制函数。
实现的效果如下:
222
具体的实现过程如下:
- 在
screen-short.vue
中,创建截图工具栏div并布局好其样式
<template> <teleport to="body"> <!--工具栏--> <div id="toolPanel" v-show="toolStatus" :style="{ left: toolLeft + 'px', top: toolTop + 'px' }" ref="toolController" > <div v-for="item in toolbar" :key="item.id" :class="`item-panel ${item.title} `" @click="toolClickEvent(item.title, item.id, $event)" ></div> <!--撤销部分单独处理--> <div v-if="undoStatus" class="item-panel undo" @click="toolClickEvent('undo', 9, $event)" ></div> <div v-else class="item-panel undo-disabled"></div> <!--关闭与确认进行单独处理--> <div class="item-panel close" @click="toolClickEvent('close', 10, $event)" ></div> <div class="item-panel confirm" @click="toolClickEvent('confirm', 11, $event)" ></div> </div> </teleport> </template> <script lang="ts"> import eventMonitoring from "@/module/main-entrance/EventMonitoring"; import toolbar from "@/module/config/Toolbar.ts"; export default { name: "screen-short", setup(props: Record<string, any>, context: SetupContext<any>) { const event = new eventMonitoring(props, context as SetupContext<any>); const toolClickEvent = event.toolClickEvent; return { toolClickEvent, toolbar } } } </script>
⚠️上述代码仅展示了组件的部分代码,完整代码请移步:screen-short.vue、screen-short.scss
截图工具条目点击样式处理
截图工具栏中的每一个条目都拥有三种状态:正常状态、鼠标移入、点击,此处我的做法是将所有状态写在css里了,通过不同的class名来显示不同的样式。
部分工具栏点击状态的css如下:
.square-active { background-image: url("~@/assets/img/square-click.png"); } .round-active { background-image: url("~@/assets/img/round-click.png"); } .right-top-active { background-image: url("~@/assets/img/right-top-click.png"); }
一开始我想在v-for渲染时,定义一个变量,点击时改变这个变量的状态,显示每个点击条目对应的点击时的样式,但是我在做的时候却发现问题了,我的点击时的class名是动态的,没法通过这种形式来弄,无奈我只好选择dom操作的形式来实现,点击时传$event
到函数,获取当前点击项点击时的class,判断其是否有选中的class,如果有就删除,然后为当前点击项添加class。
实现代码如下:
- dom结构
<div v-for="item in toolbar" :key="item.id" :class="`item-panel ${item.title} `" @click="toolClickEvent(item.title, item.id, $event)" ></div>
- 工具栏点击事件
/** * 裁剪框工具栏点击事件 * @param toolName * @param index * @param mouseEvent */ public toolClickEvent = ( toolName: string, index: number, mouseEvent: MouseEvent ) => { // 为当前点击项添加选中时的class名 setSelectedClassName(mouseEvent, index, false); }
- 为当前点击项添加选中时的class,移除其兄弟元素选中时的class
import { getSelectedClassName } from "@/module/common-methords/GetSelectedCalssName"; import { getBrushSelectedName } from "@/module/common-methords/GetBrushSelectedName"; /** * 为当前点击项添加选中时的class,移除其兄弟元素选中时的class * @param mouseEvent 需要进行操作的元素 * @param index 当前点击项 * @param isOption 是否为画笔选项 */ export function setSelectedClassName( mouseEvent: any, index: number, isOption: boolean ) { // 获取当前点击项选中时的class名 let className = getSelectedClassName(index); if (isOption) { // 获取画笔选项选中时的对应的class className = getBrushSelectedName(index); } // 获取div下的所有子元素 const nodes = mouseEvent.path[1].children; for (let i = 0; i < nodes.length; i++) { const item = nodes[i]; // 如果工具栏中已经有选中的class则将其移除 if (item.className.includes("active")) { item.classList.remove(item.classList[2]); } } // 给当前点击项添加选中时的class mouseEvent.target.className += " " + className; }
- 获取截图工具栏点击时的class名
export function getSelectedClassName(index: number) { let className = ""; switch (index) { case 1: className = "square-active"; break; case 2: className = "round-active"; break; case 3: className = "right-top-active"; break; case 4: className = "brush-active"; break; case 5: className = "mosaicPen-active"; break; case 6: className = "text-active"; } return className; }
- 获取画笔选择点击时的class名
/** * 获取画笔选项对应的选中时的class名 * @param itemName */ export function getBrushSelectedName(itemName: number) { let className = ""; switch (itemName) { case 1: className = "brush-small-active"; break; case 2: className = "brush-medium-active"; break; case 3: className = "brush-big-active"; break; } return className; }
实现工具栏中的每个选项
接下来,我们来看看工具栏中每个选项的具体实现。
工具栏中每个图形的绘制都需要鼠标按下、移动、抬起这三个事件的配合下完成,为了防止鼠标在移动时图形重复绘制,这里我们采用"历史记录"模式来解决这个问题,我们先来看下重复绘制时的场景,如下所示:
接下来,我们来看下如何使用历史记录来解决这个问题。
- 首先,我们需要定义一个数组变量,取名为
history
。
private history: Array<Record<string, any>> = [];
- 当图形绘制结束鼠标抬起时,将当前画布状态保存至
history
。
/** * 保存当前画布状态 * @private */ private addHistoy() { if ( this.screenShortCanvas != null && this.screenShortController.value != null ) { // 获取canvas画布与容器 const context = this.screenShortCanvas; const controller = this.screenShortController.value; if (this.history.length > this.maxUndoNum) { // 删除最早的一条画布记录 this.history.unshift(); } // 保存当前画布状态 this.history.push({ data: context.getImageData(0, 0, controller.width, controller.height) }); // 启用撤销按钮 this.data.setUndoStatus(true); } }
- 当鼠标处于移动状态时,我们取出
history
中最后一条记录。
/** * 显示最新的画布状态 * @private */ private showLastHistory() { if (this.screenShortCanvas != null) { const context = this.screenShortCanvas; if (this.history.length <= 0) { this.addHistoy(); } context.putImageData(this.history[this.history.length - 1]["data"], 0, 0); } }
上述函数放在合适的时机执行,即可解决图形重复绘制的问题,接下来我们看下解决后的绘制效果,如下所示:
实现矩形绘制
在前面的分析中,我们拿到了鼠标的起始点坐标和鼠标移动时的坐标,我们可以通过这些数据计算出框选区域的宽高,如下所示。
// 获取鼠标起始点坐标 const { startX, startY } = this.drawGraphPosition; // 获取当前鼠标坐标 const currentX = nonNegativeData(event.offsetX); const currentY = nonNegativeData(event.offsetY); // 裁剪框临时宽高 const tempWidth = currentX - startX; const tempHeight = currentY - startY;
我们拿到这些数据后,即可通过canvas的rect这个API来绘制一个矩形了,代码如下所示:
/** * 绘制矩形 * @param mouseX * @param mouseY * @param width * @param height * @param color 边框颜色 * @param borderWidth 边框大小 * @param context 需要进行绘制的canvas画布 * @param controller 需要进行操作的canvas容器 * @param imageController 图片canvas容器 */ export function drawRectangle( mouseX: number, mouseY: number, width: number, height: number, color: string, borderWidth: number, context: CanvasRenderingContext2D, controller: HTMLCanvasElement, imageController: HTMLCanvasElement ) { context.save(); // 设置边框颜色 context.strokeStyle = color; // 设置边框大小 context.lineWidth = borderWidth; context.beginPath(); // 绘制矩形 context.rect(mouseX, mouseY, width, height); context.stroke(); // 绘制结束 context.restore(); // 使用drawImage将图片绘制到蒙层下方 context.save(); context.globalCompositeOperation = "destination-over"; context.drawImage( imageController, 0, 0, controller?.width, controller?.height ); // 绘制结束 context.restore(); }
实现椭圆绘制
在绘制椭圆时,我们需要根据坐标信息计算出圆的半径、圆心坐标,随后调用ellipse函数即可绘制一个椭圆出来,代码如下所示:
/** * 绘制圆形 * @param context 需要进行绘制的画布 * @param mouseX 当前鼠标x轴坐标 * @param mouseY 当前鼠标y轴坐标 * @param mouseStartX 鼠标按下时的x轴坐标 * @param mouseStartY 鼠标按下时的y轴坐标 * @param borderWidth 边框宽度 * @param color 边框颜色 */ export function drawCircle( context: CanvasRenderingContext2D, mouseX: number, mouseY: number, mouseStartX: number, mouseStartY: number, borderWidth: number, color: string ) { // 坐标边界处理,解决反向绘制椭圆时的报错问题 const startX = mouseX < mouseStartX ? mouseX : mouseStartX; const startY = mouseY < mouseStartY ? mouseY : mouseStartY; const endX = mouseX >= mouseStartX ? mouseX : mouseStartX; const endY = mouseY >= mouseStartY ? mouseY : mouseStartY; // 计算圆的半径 const radiusX = (endX - startX) * 0.5; const radiusY = (endY - startY) * 0.5; // 计算圆心的x、y坐标 const centerX = startX + radiusX; const centerY = startY + radiusY; // 开始绘制 context.save(); context.beginPath(); context.lineWidth = borderWidth; context.strokeStyle = color; if (typeof context.ellipse === "function") { // 绘制圆,旋转角度与起始角度都为0,结束角度为2*PI context.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI); } else { throw "你的浏览器不支持ellipse,无法绘制椭圆"; } context.stroke(); context.closePath(); // 结束绘制 context.restore(); }
⚠️注释已经写的很清楚了,此处用到的API有:beginPath、lineWidth、ellipse、closePath,对这些API不熟悉的开发者请移步到指定位置进行查阅。
实现箭头绘制
箭头绘制相比其他工具来说是最复杂的,因为我们需要通过三角函数来计算箭头两个点的坐标,通过三角函数中的反正切函数来计算箭头的角度
既然需要用到三角函数来实现,那我们先来看下我们的已知条件:
/** * 已知: * 1. P1、P2的坐标 * 2. 箭头斜线P3到P2直线的长度,P4与P3是对称的,因此P4到P2的长度等于P3到P2的长度 * 3. 箭头斜线P3到P1、P2直线的夹角角度(θ),因为是对称的,所以P4与P1、P2直线的夹角角度是相等的 * 求: * P3、P4的坐标 */
如上图所示,P1为鼠标按下时的坐标,P2为鼠标移动时的坐标,夹角θ的角度为30,我们知道这些信息后就可以求出P3和P4的坐标了,求出坐标后我们即可通过canvas的moveTo、lineTo来绘制箭头了。
实现代码如下:
/** * 绘制箭头 * @param context 需要进行绘制的画布 * @param mouseStartX 鼠标按下时的x轴坐标 P1 * @param mouseStartY 鼠标按下时的y轴坐标 P1 * @param mouseX 当前鼠标x轴坐标 P2 * @param mouseY 当前鼠标y轴坐标 P2 * @param theta 箭头斜线与直线的夹角角度 (θ) P3 ---> (P1、P2) || P4 ---> P1(P1、P2) * @param headlen 箭头斜线的长度 P3 ---> P2 || P4 ---> P2 * @param borderWidth 边框宽度 * @param color 边框颜色 */ export function drawLineArrow( context: CanvasRenderingContext2D, mouseStartX: number, mouseStartY: number, mouseX: number, mouseY: number, theta: number, headlen: number, borderWidth: number, color: string ) { /** * 已知: * 1. P1、P2的坐标 * 2. 箭头斜线(P3 || P4) ---> P2直线的长度 * 3. 箭头斜线(P3 || P4) ---> (P1、P2)直线的夹角角度(θ) * 求: * P3、P4的坐标 */ const angle = (Math.atan2(mouseStartY - mouseY, mouseStartX - mouseX) * 180) / Math.PI, // 通过atan2来获取箭头的角度 angle1 = ((angle + theta) * Math.PI) / 180, // P3点的角度 angle2 = ((angle - theta) * Math.PI) / 180, // P4点的角度 topX = headlen * Math.cos(angle1), // P3点的x轴坐标 topY = headlen * Math.sin(angle1), // P3点的y轴坐标 botX = headlen * Math.cos(angle2), // P4点的X轴坐标 botY = headlen * Math.sin(angle2); // P4点的Y轴坐标 // 开始绘制 context.save(); context.beginPath(); // P3的坐标位置 let arrowX = mouseStartX - topX, arrowY = mouseStartY - topY; // 移动笔触到P3坐标 context.moveTo(arrowX, arrowY); // 移动笔触到P1 context.moveTo(mouseStartX, mouseStartY); // 绘制P1到P2的直线 context.lineTo(mouseX, mouseY); // 计算P3的位置 arrowX = mouseX + topX; arrowY = mouseY + topY; // 移动笔触到P3坐标 context.moveTo(arrowX, arrowY); // 绘制P2到P3的斜线 context.lineTo(mouseX, mouseY); // 计算P4的位置 arrowX = mouseX + botX; arrowY = mouseY + botY; // 绘制P2到P4的斜线 context.lineTo(arrowX, arrowY); // 上色 context.strokeStyle = color; context.lineWidth = borderWidth; // 填充 context.stroke(); // 结束绘制 context.restore(); }
⚠️此处用到的新API有:moveTo、lineTo,对这些API不熟悉的开发者请移步到指定位置进行查阅。
实现画笔绘制
画笔的绘制我们需要通过lineTo来实现,不过在绘制时需要注意:在鼠标按下时需要通过beginPath来清空一条路径,并移动画笔笔触到鼠标按下时的位置,否则鼠标的起始位置始终是0,bug如下所示:
那么要解决这个bug,就需要在鼠标按下时初始化一下笔触位置,代码如下:
/** * 画笔初始化 */ export function initPencli( context: CanvasRenderingContext2D, mouseX: number, mouseY: number ) { // 开始||清空一条路径 context.beginPath(); // 移动画笔位置 context.moveTo(mouseX, mouseY); }
随后,在鼠标位置时根据坐标信息绘制线条即可,代码如下:
/** * 画笔绘制 * @param context * @param mouseX * @param mouseY * @param size * @param color */ export function drawPencli( context: CanvasRenderingContext2D, mouseX: number, mouseY: number, size: number, color: string ) { // 开始绘制 context.save(); // 设置边框大小 context.lineWidth = size; // 设置边框颜色 context.strokeStyle = color; context.lineTo(mouseX, mouseY); context.stroke(); // 绘制结束 context.restore(); }
实现马赛克绘制
我们都知道图片是由一个个像素点构成的,当我们把某个区域的像素点设置成同样的颜色,这块区域的信息就会被破坏掉,被我们破坏掉的区域就叫马赛克。
知道马赛克的原理后,我们就可以分析出实现思路:
- 获取鼠标划过路径区域的图像信息
- 将区域内的像素点绘制成周围相近的颜色
具体的实现代码如下:
/** * 获取图像指定坐标位置的颜色 * @param imgData 需要进行操作的图片 * @param x x点坐标 * @param y y点坐标 */ const getAxisColor = (imgData: ImageData, x: number, y: number) => { const w = imgData.width; const d = imgData.data; const color = []; color[0] = d[4 * (y * w + x)]; color[1] = d[4 * (y * w + x) + 1]; color[2] = d[4 * (y * w + x) + 2]; color[3] = d[4 * (y * w + x) + 3]; return color; }; /** * 设置图像指定坐标位置的颜色 * @param imgData 需要进行操作的图片 * @param x x点坐标 * @param y y点坐标 * @param color 颜色数组 */ const setAxisColor = ( imgData: ImageData, x: number, y: number, color: Array<number> ) => { const w = imgData.width; const d = imgData.data; d[4 * (y * w + x)] = color[0]; d[4 * (y * w + x) + 1] = color[1]; d[4 * (y * w + x) + 2] = color[2]; d[4 * (y * w + x) + 3] = color[3]; }; /** * 绘制马赛克 * 实现思路: * 1. 获取鼠标划过路径区域的图像信息 * 2. 将区域内的像素点绘制成周围相近的颜色 * @param mouseX 当前鼠标X轴坐标 * @param mouseY 当前鼠标Y轴坐标 * @param size 马赛克画笔大小 * @param degreeOfBlur 马赛克模糊度 * @param context 需要进行绘制的画布 */ export function drawMosaic( mouseX: number, mouseY: number, size: number, degreeOfBlur: number, context: CanvasRenderingContext2D ) { // 获取鼠标经过区域的图片像素信息 const imgData = context.getImageData(mouseX, mouseY, size, size); // 获取图像宽高 const w = imgData.width; const h = imgData.height; // 等分图像宽高 const stepW = w / degreeOfBlur; const stepH = h / degreeOfBlur; // 循环画布像素点 for (let i = 0; i < stepH; i++) { for (let j = 0; j < stepW; j++) { // 随机获取一个小方格的随机颜色 const color = getAxisColor( imgData, j * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur), i * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur) ); // 循环小方格的像素点 for (let k = 0; k < degreeOfBlur; k++) { for (let l = 0; l < degreeOfBlur; l++) { // 设置小方格的颜色 setAxisColor( imgData, j * degreeOfBlur + l, i * degreeOfBlur + k, color ); } } } } // 渲染打上马赛克后的图像信息 context.putImageData(imgData, mouseX, mouseY); }
实现文字绘制
canvas没有直接提供API来供我们输入文字,但是它提供了填充文本的API,因此我们需要一个div来让用户输入文字,用户输入完成后将输入的文字填充到指定区域即可。
实现的效果如下:
1258
- 在组件中创建一个div,开启div的可编辑属性,布局好样式
<template> <teleport to="body"> <!--文本输入区域--> <div id="textInputPanel" ref="textInputController" v-show="textStatus" contenteditable="true" spellcheck="false" ></div> </teleport> </template>
- 鼠标按下时,计算文本输入区域位置
// 计算文本框显示位置 const textMouseX = mouseX - 15; const textMouseY = mouseY - 15; // 修改文本区域位置 this.textInputController.value.style.left = textMouseX + "px"; this.textInputController.value.style.top = textMouseY + "px";
- 输入框位置发生变化时代表用户输入完毕,将用户输入的内容渲染到canvas,绘制文本的代码如下
/** * 绘制文本 * @param text 需要进行绘制的文字 * @param mouseX 绘制位置的X轴坐标 * @param mouseY 绘制位置的Y轴坐标 * @param color 字体颜色 * @param fontSize 字体大小 * @param context 需要进行绘制的画布 */ export function drawText( text: string, mouseX: number, mouseY: number, color: string, fontSize: number, context: CanvasRenderingContext2D ) { // 开始绘制 context.save(); context.lineWidth = 1; // 设置字体颜色 context.fillStyle = color; context.textBaseline = "middle"; context.font = `bold ${fontSize}px 微软雅黑`; context.fillText(text, mouseX, mouseY); // 结束绘制 context.restore(); }
实现下载功能
下载功能比较简单,我们只需要将裁剪框区域的内容放进一个新的canvas中,然后调用toDataURL方法就能拿到图片的base64地址,我们创建一个a标签,添加download属性,触发a标签的点击事件即可下载。
实现代码如下:
export function saveCanvasToImage( context: CanvasRenderingContext2D, startX: number, startY: number, width: number, height: number ) { // 获取裁剪框区域图片信息 const img = context.getImageData(startX, startY, width, height); // 创建canvas标签,用于存放裁剪区域的图片 const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; // 获取裁剪框区域画布 const imgContext = canvas.getContext("2d"); if (imgContext) { // 将图片放进裁剪框内 imgContext.putImageData(img, 0, 0); const a = document.createElement("a"); // 获取图片 a.href = canvas.toDataURL("png"); // 下载图片 a.download = `${new Date().getTime()}.png`; a.click(); } }
实现撤销功能
由于我们绘制图形采用了历史记录模式,每次图形绘制都会存储一次画布状态,我们只需要在点击撤销按钮时,从history弹出一最后一条记录即可。
实现代码如下:
/** * 取出一条历史记录 */ private takeOutHistory() { const lastImageData = this.history.pop(); if (this.screenShortCanvas != null && lastImageData) { const context = this.screenShortCanvas; if (this.undoClickNum == 0 && this.history.length > 0) { // 首次取出需要取两条历史记录 const firstPopImageData = this.history.pop() as Record<string, any>; context.putImageData(firstPopImageData["data"], 0, 0); } else { context.putImageData(lastImageData["data"], 0, 0); } } this.undoClickNum++; // 历史记录已取完,禁用撤回按钮点击 if (this.history.length <= 0) { this.undoClickNum = 0; this.data.setUndoStatus(false); } }
实现关闭功能
关闭功能指的是重置截图组件,因此我们需要通过emit向父组件推送销毁的消息。
实现代码如下:
/** * 重置组件 */ private resetComponent = () => { if (this.emit) { // 隐藏截图工具栏 this.data.setToolStatus(false); // 初始化响应式变量 this.data.setInitStatus(true); // 销毁组件 this.emit("destroy-component", false); return; } throw "组件重置失败"; };
实现确认功能
当用户点击确认后,我们需要将裁剪框内的内容转为base64,然后通过emit推送给父组件,最后重置组件。
实现代码如下:
const base64 = this.getCanvasImgData(false); this.emit("get-image-data", base64);
插件地址
至此,插件的实现过程就分享完毕了。
- 插件在线体验地址:chat-system
- 插件GitHub仓库地址:screen-shot
- 开源项目地址:chat-system-github
写在最后
- 文章中gif图较大,可能无法查看,可点击下方阅读原文查看😊
- 公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊