前言
当客户在使用我们的产品过程中,遇到问题需要向我们反馈时,如果用纯文字的形式描述,我们很难懂客户的意思,要是能配上问题截图,这样我们就能很清楚的知道客户的问题了。
那么,我们就需要为我们的产品实现一个自定义截屏的功能,用户点完"截图"按钮后,框选任意区域,随后在框选的区域内进行圈选、画箭头、马赛克、直线、打字等操作,做完操作后用户可以选择保存框选区域的内容到本地或者直接发送给我们。
聪明的开发者可能已经猜到了,这是QQ/微信的截图功能,我的开源项目正好做到了截图功能,在做之前我找了很多资料,没有发现web端有这种东西存在,于是我就决定参照QQ的截图自己实现一个并做成插件供大家使用。
本文就跟大家分享下我在做这个"自定义截屏功能"时的实现思路以及过程,欢迎各位感兴趣的开发者阅读本文。
写在前面
本文插件的写法采用的是Vue3的compositionAPI,如果对其不了解的开发者请移步我的另一篇文章:使用Vue3的CompositionAPI来优化代码量
实现思路
我们先来看下QQ的截屏流程,进而分析它是怎么实现的。
截屏流程分析
我们先来分析下,截屏时的具体流程。
- 点击截屏按钮后,我们会发现页面上所有动态效果都静止不动了,如下所示。
- 随后,我们按住鼠标左键进行拖动,屏幕上会出现黑色蒙板,鼠标的拖动区域会出现镂空效果,如下所示(此处图片过大,无法展示请移步原文查看)
- 完成拖拽后,框选区域的下方会出现工具栏,里面有框选、圈选、箭头、直线、画笔等工具,如下图所示。
image-20210201142541572
- 点击工具栏中任意一个图标,会出现画笔选择区域,在这里可以选择画笔大小、颜色如下所示。
- 随后,我们在框选的区域内进行拖拽就会绘制出对应的图形,如下所示。
image-20210201144004992
- 最后,点击截图工具栏的下载图标即可将图片保存至本地,或者点击对号图片会自动粘贴到聊天输入框,如下所示。
截屏实现思路
通过上述截屏流程,我们便得到了下述实现思路:
- 获取当前可视区域的内容,将其存储起来
- 为整个cnavas画布绘制蒙层
- 在获取到的内容中进行拖拽,绘制镂空选区
- 选择截图工具栏的工具,选择画笔大小等信息
- 在选区内拖拽绘制对应的图形
- 将选区内的内容转换为图片
实现过程
我们分析出了实现思路,接下来我们将上述思路逐一进行实现。
获取当前可视区域内容
当点击截图按钮后,我们需要获取整个可视区域的内容,后续所有的操作都是在获取的内容上进行的,在web端我们可以使用canvas来实现这些操作。
那么,我们就需要先将body区域的内容转换为canvas,如果要从零开始实现这个转换,有点复杂而且工作量很大。
还好在前端社区中有个开源库叫html2canvas可以实现将指定dom转换为canvas,我们就采用这个库来实现我们的转换。
接下来,我们来看下具体实现过程:
新建一个名为screen-short.vue
的文件,用于承载我们的整个截图组件。
- 首先我们需要一个canvas容器来显示转换后的可视区域内容
<template> <teleport to="body"> <!--截图区域--> <canvas id="screenShotContainer" :width="screenShortWidth" :height="screenShortHeight" ref="screenShortController" ></canvas> </teleport> </template>
此处只展示了部分代码,完整代码请移步:screen-short.vue
- 在组件挂载时,调用html2canvas提供的方法,将body中的内容转换为canvas,存储起来。
import html2canvas from "html2canvas"; import InitData from "@/module/main-entrance/InitData"; export default class EventMonitoring { // 当前实例的响应式data数据 private readonly data: InitData; // 截图区域canvas容器 private screenShortController: Ref<HTMLCanvasElement | null>; // 截图图片存放容器 private screenShortImageController: HTMLCanvasElement | undefined; constructor(props: Record<string, any>, context: SetupContext<any>) { // 实例化响应式data this.data = new InitData(); // 获取截图区域canvas容器 this.screenShortController = this.data.getScreenShortController(); onMounted(() => { // 设置截图区域canvas宽高 this.data.setScreenShortInfo(window.innerWidth, window.innerHeight); html2canvas(document.body, {}).then(canvas => { // 装载截图的dom为null则退出 if (this.screenShortController.value == null) return; // 存放html2canvas截取的内容 this.screenShortImageController = canvas; }) }) } }
此处只展示了部分代码,完整代码请移步:EventMonitoring.ts
为canvas画布绘制蒙层
我们拿到了转换后的dom后,我们就需要绘制一个透明度为0.6的黑色蒙层,告知用户你现在处于截屏区域选区状态。
具体实现过程如下:
- 创建DrawMasking.ts文件,蒙层的绘制逻辑在此文件中实现,代码如下。
/** * 绘制蒙层 * @param context 需要进行绘制canvas */ export function drawMasking(context: CanvasRenderingContext2D) { // 清除画布 context.clearRect(0, 0, window.innerWidth, window.innerHeight); // 绘制蒙层 context.save(); context.fillStyle = "rgba(0, 0, 0, .6)"; context.fillRect(0, 0, window.innerWidth, window.innerHeight); // 绘制结束 context.restore(); }
⚠️注释已经写的很详细了,对上述API不懂的开发者请移步:clearRect、save、fillStyle、fillRect、restore
- 在
html2canvas
函数回调中调用绘制蒙层函数
html2canvas(document.body, {}).then(canvas => { // 获取截图区域画canvas容器画布 const context = this.screenShortController.value?.getContext("2d"); if (context == null) return; // 绘制蒙层 drawMasking(context); })
绘制镂空选区
我们在黑色蒙层中拖拽时,需要获取鼠标按下时的起始点坐标以及鼠标移动时的坐标,根据起始点坐标和移动时的坐标,我们就可以得到一个区域,此时我们将这块区域的蒙层凿开,将获取到的canvas图片内容绘制到蒙层下方,这样我们就实现了镂空选区效果。
整理下上述话语,思路如下:
- 监听鼠标按下、移动、抬起事件
- 获取鼠标按下、移动时的坐标
- 根据获取到的坐标凿开蒙层
- 将获取到的canvas图片内容绘制到蒙层下方
- 实现镂空选区的拖拽与缩放
实现的效果如下:
具体代码如下:
export default class EventMonitoring { // 当前实例的响应式data数据 private readonly data: InitData; // 截图区域canvas容器 private screenShortController: Ref<HTMLCanvasElement | null>; // 截图图片存放容器 private screenShortImageController: HTMLCanvasElement | undefined; // 截图区域画布 private screenShortCanvas: CanvasRenderingContext2D | undefined; // 图形位置参数 private drawGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 }; // 临时图形位置参数 private tempGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 }; // 裁剪框边框节点坐标事件 private cutOutBoxBorderArr: Array<cutOutBoxBorder> = []; // 裁剪框顶点边框直径大小 private borderSize = 10; // 当前操作的边框节点 private borderOption: number | null = null; // 点击裁剪框时的鼠标坐标 private movePosition: movePositionType = { moveStartX: 0, moveStartY: 0 }; // 裁剪框修剪状态 private draggingTrim = false; // 裁剪框拖拽状态 private dragging = false; // 鼠标点击状态 private clickFlag = false; constructor(props: Record<string, any>, context: SetupContext<any>) { // 实例化响应式data this.data = new InitData(); // 获取截图区域canvas容器 this.screenShortController = this.data.getScreenShortController(); onMounted(() => { // 设置截图区域canvas宽高 this.data.setScreenShortInfo(window.innerWidth, window.innerHeight); html2canvas(document.body, {}).then(canvas => { // 装载截图的dom为null则退出 if (this.screenShortController.value == null) return; // 存放html2canvas截取的内容 this.screenShortImageController = canvas; // 获取截图区域画canvas容器画布 const context = this.screenShortController.value?.getContext("2d"); if (context == null) return; // 赋值截图区域canvas画布 this.screenShortCanvas = context; // 绘制蒙层 drawMasking(context); // 添加监听 this.screenShortController.value?.addEventListener( "mousedown", this.mouseDownEvent ); this.screenShortController.value?.addEventListener( "mousemove", this.mouseMoveEvent ); this.screenShortController.value?.addEventListener( "mouseup", this.mouseUpEvent ); }) }) } // 鼠标按下事件 private mouseDownEvent = (event: MouseEvent) => { this.dragging = true; this.clickFlag = true; const mouseX = nonNegativeData(event.offsetX); const mouseY = nonNegativeData(event.offsetY); // 如果操作的是裁剪框 if (this.borderOption) { // 设置为拖动状态 this.draggingTrim = true; // 记录移动时的起始点坐标 this.movePosition.moveStartX = mouseX; this.movePosition.moveStartY = mouseY; } else { // 绘制裁剪框,记录当前鼠标开始坐标 this.drawGraphPosition.startX = mouseX; this.drawGraphPosition.startY = mouseY; } } // 鼠标移动事件 private mouseMoveEvent = (event: MouseEvent) => { this.clickFlag = false; // 获取裁剪框位置信息 const { startX, startY, width, height } = this.drawGraphPosition; // 获取当前鼠标坐标 const currentX = nonNegativeData(event.offsetX); const currentY = nonNegativeData(event.offsetY); // 裁剪框临时宽高 const tempWidth = currentX - startX; const tempHeight = currentY - startY; // 执行裁剪框操作函数 this.operatingCutOutBox( currentX, currentY, startX, startY, width, height, this.screenShortCanvas ); // 如果鼠标未点击或者当前操作的是裁剪框都return if (!this.dragging || this.draggingTrim) return; // 绘制裁剪框 this.tempGraphPosition = drawCutOutBox( startX, startY, tempWidth, tempHeight, this.screenShortCanvas, this.borderSize, this.screenShortController.value as HTMLCanvasElement, this.screenShortImageController as HTMLCanvasElement ) as drawCutOutBoxReturnType; } // 鼠标抬起事件 private mouseUpEvent = () => { // 绘制结束 this.dragging = false; this.draggingTrim = false; // 保存绘制后的图形位置信息 this.drawGraphPosition = this.tempGraphPosition; // 如果工具栏未点击则保存裁剪框位置 if (!this.data.getToolClickStatus().value) { const { startX, startY, width, height } = this.drawGraphPosition; this.data.setCutOutBoxPosition(startX, startY, width, height); } // 保存边框节点信息 this.cutOutBoxBorderArr = saveBorderArrInfo( this.borderSize, this.drawGraphPosition ); } }
⚠️绘制镂空选区的代码较多,此处仅仅展示了鼠标的三个事件监听的相关代码,完整代码请移步:EventMonitoring.ts
- 绘制裁剪框的代码如下
/** * 绘制裁剪框 * @param mouseX 鼠标x轴坐标 * @param mouseY 鼠标y轴坐标 * @param width 裁剪框宽度 * @param height 裁剪框高度 * @param context 需要进行绘制的canvas画布 * @param borderSize 边框节点直径 * @param controller 需要进行操作的canvas容器 * @param imageController 图片canvas容器 * @private */ export function drawCutOutBox( mouseX: number, mouseY: number, width: number, height: number, context: CanvasRenderingContext2D, borderSize: number, controller: HTMLCanvasElement, imageController: HTMLCanvasElement ) { // 获取画布宽高 const canvasWidth = controller?.width; const canvasHeight = controller?.height; // 画布、图片不存在则return if (!canvasWidth || !canvasHeight || !imageController || !controller) return; // 清除画布 context.clearRect(0, 0, canvasWidth, canvasHeight); // 绘制蒙层 context.save(); context.fillStyle = "rgba(0, 0, 0, .6)"; context.fillRect(0, 0, canvasWidth, canvasHeight); // 将蒙层凿开 context.globalCompositeOperation = "source-atop"; // 裁剪选择框 context.clearRect(mouseX, mouseY, width, height); // 绘制8个边框像素点并保存坐标信息以及事件参数 context.globalCompositeOperation = "source-over"; context.fillStyle = "#2CABFF"; // 像素点大小 const size = borderSize; // 绘制像素点 context.fillRect(mouseX - size / 2, mouseY - size / 2, size, size); context.fillRect( mouseX - size / 2 + width / 2, mouseY - size / 2, size, size ); context.fillRect(mouseX - size / 2 + width, mouseY - size / 2, size, size); context.fillRect( mouseX - size / 2, mouseY - size / 2 + height / 2, size, size ); context.fillRect( mouseX - size / 2 + width, mouseY - size / 2 + height / 2, size, size ); context.fillRect(mouseX - size / 2, mouseY - size / 2 + height, size, size); context.fillRect( mouseX - size / 2 + width / 2, mouseY - size / 2 + height, size, size ); context.fillRect( mouseX - size / 2 + width, mouseY - size / 2 + height, size, size ); // 绘制结束 context.restore(); // 使用drawImage将图片绘制到蒙层下方 context.save(); context.globalCompositeOperation = "destination-over"; context.drawImage( imageController, 0, 0, controller?.width, controller?.height ); context.restore(); // 返回裁剪框临时位置信息 return { startX: mouseX, startY: mouseY, width: width, height: height }; }