vue下如何利用canvas实现在线图片标注
时间:2022-06-13 10:14:32|栏目:vue|点击: 次
web端实现在线图片标注在此做下记录,功能类似微信截图时的标注,包含画线、框、箭头和文字输入,思路是利用canvas画布,先把要标注的图片使用drawImage方法画在画布上,然后定义画线、框、箭头和文字输入的方法调用
组件代码如下
<template> <div class="draw"> <div class="drawTop" ref="drawTop" v-if="lineStep == lineNum"> <div> <el-button type @click="resetAll">清空</el-button> <el-button type @click="repeal">撤销</el-button> <el-button type @click="canvasRedo">恢复</el-button> <el-button type @click="downLoad">下载</el-button> </div> <div style="width:22%"> 选择绘制类型: <el-radio-group v-model="type" size="medium"> <el-radio-button v-for="(item,index) in typeOption" :key="index" :label="item.value" @click.native="radioClick(item.value)" >{{item.label}} </el-radio-button> </el-radio-group> </div> <div style="width:15%"> 边框粗细: <el-slider v-model="lineWidth" :min="0" :max="10" :step="1" style="width:70%"></el-slider> </div> <div> 线条颜色: <el-color-picker v-model="strokeStyle"></el-color-picker> </div> <div> 文字颜色: <el-color-picker v-model="fontColor"></el-color-picker> </div> <div style="width:15%"> 文字大小: <el-slider v-model="fontSize" :min="14" :max="36" :step="2" style="width:70%"></el-slider> </div> </div> <div style="height: 100%;width: 100%;position:relative;"> <div class="content"></div> <input v-show="isShow" type="text" @blur="txtBlue" ref="txt" id="txt" style="z-index: 9999;position: absolute;border: 0;background:none;outline: none;"/> </div> </div> </template>
<script> export default { name: "callout", props: { imgPath: undefined, }, data() { return { isShow: false, canvas: "", ctx: "", ctxX: 0, ctxY: 0, lineWidth: 1, type: "L", typeOption: [ {label: "线", value: "L"}, {label: "矩形", value: "R"}, {label: "箭头", value: "A"}, {label: "文字", value: "T"}, ], canvasHistory: [], step: 0, loading: false, fillStyle: "#CB0707", strokeStyle: "#CB0707", lineNum: 2, linePeak: [], lineStep: 2, ellipseR: 0.5, dialogVisible: false, isUnfold: true, fontSize: 24, fontColor: "#CB0707", fontFamily: '微软雅黑', img: new Image(), }; }, mounted() { let _this = this; let image = new Image(); image.setAttribute('crossOrigin', 'anonymous'); image.src = this.imgPath; image.onload = function () {//图片加载完,再draw 和 toDataURL if (image.complete) { _this.img = image let content = document.getElementsByClassName("content")[0]; _this.canvas = document.createElement("canvas"); _this.canvas.height = _this.img.height _this.canvas.width = _this.img.width _this.ctx = _this.canvas.getContext("2d"); _this.ctx.globalAlpha = 1; _this.ctx.drawImage(_this.img, 0, 0) _this.canvasHistory.push(_this.canvas.toDataURL()); _this.ctx.globalCompositeOperation = _this.type; content.appendChild(_this.canvas); _this.bindEventLisner(); } } }, methods: { radioClick(item) { if (item != "T") { this.txtBlue() this.resetTxt() } }, // 下载画布 downLoad() { let _this = this; let url = _this.canvas.toDataURL("image/png"); let fileName = "canvas.png"; if ("download" in document.createElement("a")) { // 非IE下载 const elink = document.createElement("a"); elink.download = fileName; elink.style.display = "none"; elink.href = url; document.body.appendChild(elink); elink.click(); document.body.removeChild(elink); } else { // IE10+下载 navigator.msSaveBlob(url, fileName); } }, // 清空画布及历史记录 resetAll() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.canvasHistory = []; this.ctx.drawImage(this.img, 0, 0); this.canvasHistory.push(this.canvas.toDataURL()); this.step = 0; this.resetTxt(); }, // 清空当前画布 reset() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(this.img, 0, 0); this.resetTxt(); }, // 撤销方法 repeal() { let _this = this; if (this.isShow) { _this.resetTxt(); _this._repeal(); } else { _this._repeal(); } }, _repeal() { if (this.step >= 1) { this.step = this.step - 1; let canvasPic = new Image(); canvasPic.src = this.canvasHistory[this.step]; canvasPic.addEventListener("load", () => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(canvasPic, 0, 0); this.loading = true; }); } else { this.$message.warning("不能再继续撤销了"); } }, // 恢复方法 canvasRedo() { if (this.step < this.canvasHistory.length - 1) { if (this.step == 0) { this.step = 1; } else { this.step++; } let canvasPic = new Image(); canvasPic.src = this.canvasHistory[this.step]; canvasPic.addEventListener("load", () => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(canvasPic, 0, 0); }); } else { this.$message.warning("已经是最新的记录了"); } }, // 绘制历史数组中的最后一个 rebroadcast() { let canvasPic = new Image(); canvasPic.src = this.canvasHistory[this.step]; canvasPic.addEventListener("load", () => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(canvasPic, 0, 0); this.loading = true; }); }, // 绑定事件,判断分支 bindEventLisner() { let _this = this; let r1, r2; // 绘制圆形,矩形需要 this.canvas.onmousedown = function (e) { console.log("onmousedown"); if (_this.type == "L") { _this.createL(e, "begin"); } else if (_this.type == "R") { r1 = e.layerX; r2 = e.layerY; _this.createR(e, "begin", r1, r2); } else if (_this.type == "A") { _this.drawArrow(e, "begin") } else if (_this.type == "T") { _this.createT(e, "begin") } }; this.canvas.onmouseup = function (e) { console.log("onmouseup"); if (_this.type == "L") { _this.createL(e, "end"); } else if (_this.type == "R") { _this.createR(e, "end", r1, r2); r1 = null; r2 = null; } else if (_this.type == "A") { _this.drawArrow(e, "end") } else if (_this.type == "T") { _this.createT(e, "end") } }; }, // 绘制线条 createL(e, status) { let _this = this; if (status == "begin") { _this.ctx.beginPath(); _this.ctx.moveTo(e.layerX, e.layerY); _this.canvas.onmousemove = function (e) { console.log("onmousemove"); _this.ctx.lineTo(e.layerX, e.layerY); _this.ctx.strokeStyle = _this.strokeStyle; _this.ctx.lineWidth = _this.lineWidth; _this.ctx.stroke(); }; } else if (status == "end") { _this.ctx.closePath(); _this.step = _this.step + 1; if (_this.step < _this.canvasHistory.length - 1) { _this.canvasHistory.length = _this.step; // 截断数组 } _this.canvasHistory.push(_this.canvas.toDataURL()); _this.canvas.onmousemove = null; } }, // 绘制矩形 createR(e, status, r1, r2) { let _this = this; let r; if (status == "begin") { console.log("onmousemove"); _this.canvas.onmousemove = function (e) { _this.reset(); let rx = e.layerX - r1; let ry = e.layerY - r2; //保留之前绘画的图形 if (_this.step !== 0) { let canvasPic = new Image(); canvasPic.src = _this.canvasHistory[_this.step]; _this.ctx.drawImage(canvasPic, 0, 0); } _this.ctx.beginPath(); _this.ctx.strokeRect(r1, r2, rx, ry); _this.ctx.strokeStyle = _this.strokeStyle; _this.ctx.lineWidth = _this.lineWidth; _this.ctx.closePath(); _this.ctx.stroke(); }; } else if (status == "end") { _this.rebroadcast(); let interval = setInterval(() => { if (_this.loading) { clearInterval(interval); _this.loading = false; } else { return; } let rx = e.layerX - r1; let ry = e.layerY - r2; _this.ctx.beginPath(); _this.ctx.rect(r1, r2, rx, ry); _this.ctx.strokeStyle = _this.strokeStyle; _this.ctx.lineWidth = _this.lineWidth; _this.ctx.closePath(); _this.ctx.stroke(); _this.step = _this.step + 1; if (_this.step < _this.canvasHistory.length - 1) { _this.canvasHistory.length = _this.step; // 截断数组 } _this.canvasHistory.push(_this.canvas.toDataURL()); _this.canvas.onmousemove = null; }, 1); } }, //绘制箭头 drawArrow(e, status) { let _this = this; if (status == "begin") { //获取起始位置 _this.arrowFromX = e.layerX; _this.arrowFromY = e.layerY; _this.ctx.beginPath(); _this.ctx.moveTo(e.layerX, e.layerY); } else if (status == "end") { //计算箭头及画线 let toX = e.layerX; let toY = e.layerY; let theta = 30; let headlen = 10; let _this = this; let fromX = this.arrowFromX; let fromY = this.arrowFromY; // 计算各角度和对应的P2,P3坐标 let angle = Math.atan2(fromY - toY, fromX - toX) * 180 / Math.PI, angle1 = (angle + theta) * Math.PI / 180, angle2 = (angle - theta) * Math.PI / 180, topX = headlen * Math.cos(angle1), topY = headlen * Math.sin(angle1), botX = headlen * Math.cos(angle2), botY = headlen * Math.sin(angle2); let arrowX = fromX - topX, arrowY = fromY - topY; _this.ctx.moveTo(arrowX, arrowY); _this.ctx.moveTo(fromX, fromY); _this.ctx.lineTo(toX, toY); arrowX = toX + topX; arrowY = toY + topY; _this.ctx.moveTo(arrowX, arrowY); _this.ctx.lineTo(toX, toY); arrowX = toX + botX; arrowY = toY + botY; _this.ctx.lineTo(arrowX, arrowY); _this.ctx.strokeStyle = _this.strokeStyle; _this.ctx.lineWidth = _this.lineWidth; _this.ctx.stroke(); _this.ctx.closePath(); _this.step = _this.step + 1; if (_this.step < _this.canvasHistory.length - 1) { _this.canvasHistory.length = _this.step; // 截断数组 } _this.canvasHistory.push(_this.canvas.toDataURL()); _this.canvas.onmousemove = null; } }, //文字输入 createT(e, status) { let _this = this; if (status == "begin") { } else if (status == "end") { let offset = 0; if (_this.fontSize >= 28) { offset = (_this.fontSize / 2) - 3 } else { offset = (_this.fontSize / 2) - 2 } _this.ctxX = e.layerX + 2; _this.ctxY = e.layerY + offset; let index = this.getPointOnCanvas(e); _this.$refs.txt.style.left = index.x + 'px'; _this.$refs.txt.style.top = index.y - (_this.fontSize / 2) + 'px'; _this.$refs.txt.value = ''; _this.$refs.txt.style.height = _this.fontSize + "px"; _this.$refs.txt.style.width = _this.canvas.width - e.layerX - 1 + "px", _this.$refs.txt.style.fontSize = _this.fontSize + "px"; _this.$refs.txt.style.fontFamily = _this.fontFamily; _this.$refs.txt.style.color = _this.fontColor; _this.$refs.txt.style.maxlength = Math.floor((_this.canvas.width - e.layerX) / _this.fontSize); _this.isShow = true; setTimeout(() => { _this.$refs.txt.focus(); }) } }, //文字输入框失去光标时在画布上生成文字 txtBlue() { let _this = this; let txt = _this.$refs.txt.value; if (txt) { _this.ctx.font = _this.$refs.txt.style.fontSize + ' ' + _this.$refs.txt.style.fontFamily; _this.ctx.fillStyle = _this.$refs.txt.style.color; _this.ctx.fillText(txt, _this.ctxX, _this.ctxY); _this.step = _this.step + 1; if (_this.step < _this.canvasHistory.length - 1) { _this.canvasHistory.length = _this.step; // 截断数组 } _this.canvasHistory.push(_this.canvas.toDataURL()); _this.canvas.onmousemove = null; } }, //计算文字框定位位置 getPointOnCanvas(e) { let cs = this.canvas; let content = document.getElementsByClassName("content")[0]; return { x: e.layerX + (content.clientWidth - cs.width) / 2, y: e.layerY }; }, //清空文字 resetTxt() { let _this = this; _this.$refs.txt.value = ''; _this.isShow = false; } } }; </script>
<style scope> * { box-sizing: border-box; } body, html, #app { overflow: hidden; } .draw { height: 100%; min-width: 420px; display: flex; flex-direction: column; } .content { flex-grow: 1; height: 100%; width: 100%; } .drawTop { display: flex; justify-content: flex-start; align-items: center; padding: 5px; height: 52px; } .drawTop > div { display: flex; align-items: center; padding: 5px 5px; } div.drawTopContrllor { display: none; } @media screen and (max-width: 1200px) { .drawTop { position: absolute; background-color: white; width: 100%; flex-direction: column; align-items: flex-start; height: 30px; overflow: hidden; } .drawTopContrllor { display: flex !important; height: 30px; width: 100%; justify-content: center; align-items: center; padding: 0 !important; } } </style>
然后在页面中引入组件,传入图片链接。
在开发过程中遇到的问题
文字输入功能在用户输入文字后,如果不再点击别的地方直接点击别的功能按钮的话,最后输入的文字将不会再画布上生成,通过监控输入框的blur事件来在画布上生成文字,避免这个问题。
文字输入时字体的大小会影响生成文字的位置,这里发现文字的大小和位置有一个偏移量:
let offset = 0; if (_this.fontSize >= 28) { offset = (_this.fontSize / 2) - 3 } else { offset = (_this.fontSize / 2) - 2 }
在画布上生成文字的时候需要加上这个偏移量,这里字体范围是14~36,别的字体大小没有校验,不一定适用这个计算方式。
绘制矩形的时候需要先清空画布,在清空之前先保存一次画布然后再清空再重新画一下画布,负责矩形框会不停的出现轨迹,并且之前画的元素会消失。
撤销的时候需要考虑文字输入,判断input得v-show是否为true,如果是true需要先清空文字,再撤销,否则画布上会一直存在一个输入框。