组件代码如下
在开发过程中遇到的问题
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需要先清空文字,再撤销,否则画布上会一直存在一个输入框。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持软件开发网。