一、功能概述
主要功能项为实现在图片上完成标注功能,简单理解,在图片坐标系上绘制矩形或者多边形,并且支持导出当前绘制矩形或者多边形的坐标。
Canvas有很多已经封装的工具库,能很快实现相应功能。但是便于学习因素,所以本次项目采用Canvas原生进行从零到一的开发。
需要实现功能为:
图片加载;
鼠标滚轮缩放;
鼠标按下拖拽;
矩形 / 多边形绘制;
矩形 / 多边形编辑;
二、功能模块
功能模块编写前置工作,需要创建一个canvas,规定canvas的大小,并且获取到canvas的dom元素(监听尺寸变化,实时修改canvas大小)
// <div ref="canvasImage" class="image-annotation-main"></div>
const _domCanvas = document.createElement('canvas')
_dom.appendChild(_domCanvas)
this.canvas = _domCanvas;
this.ctx = _domCanvas.getContext('2d')!;
// 设置Canvas大小
private resizeCanvas(_div: HTMLDivElement) {
this.canvas.width = _div.clientWidth;
this.canvas.height = _div.clientHeight;
window.addEventListener('resize', () => this.resizeCanvas(_div));
}
(1)图片加载
在Canvas上面加载图片,需要注意绘制的图片不能跨域,否则会绘制失败。
// 手动加载图片
public loadImage(_imageSrc: string, width = 0, height = 0, top = 0, left = 0) {
this.imgDom.src = _imageSrc;
this.imgDom.onload = () => {
width = width || this.imgDom.width
height = height || this.imgDom.height
this.ctx.drawImage(this.imgDom, top, left, width, height);
};
}
(2)鼠标滚轮缩放
监听鼠标滚轮事件对Canvas进行缩放操作,当用户使用鼠标滚轮时,会触发 wheel
事件,我们可以通过这个事件的 deltaY
属性来判断用户是向上滚动(放大)还是向下滚动(缩小)。
zoomIntensity 记录当前缩放精度,细致一点的话,可以稍微的动态调整缩放精度,在缩放到最下级时,可以减少精度,实现缓慢缩放的过程;
// 缩放处理
private zoomCanvas() {
this.canvas.addEventListener('wheel', e => {
e.preventDefault();
const zoomIntensity = 0.1;
const delta = e.deltaY < 0 ? 1 : -1;
const newScale = delta > 0 ? this.state.scale * (1 + zoomIntensity) : this.state.scale / (1 + zoomIntensity);
// 以鼠标位置为中心缩放
const mouseX = e.offsetX;
const mouseY = e.offsetY;
const imgPos = this.viewportToImage(mouseX, mouseY);
this.state.scale = newScale;
this.state.offsetX = mouseX - imgPos.x * this.state.scale;
this.state.offsetY = mouseY - imgPos.y * this.state.scale;
console.log(this.state)
this.draw();
}, { passive: false });
}
我们当前实现的是以当前鼠标为缩放原点,这样对用户体验最佳;你也可以选择以图片左上角、或者中心为缩放原点,但这样太过于固定,效果不佳;
通过缩放精度,计算得到当前的一个缩放比例 scale 变量,在后面重新绘制的过程中使用;
// 绘制操作
private draw() {
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 修改画布点位(左上角)
// 修改画布缩放
this.ctx.save();
this.ctx.translate(this.state.offsetX, this.state.offsetY);
this.ctx.scale(this.state.scale, this.state.scale);
// 重新绘制图片
// this.ctx.drawImage(this.imgDom, 0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.imgDom, 0, 0, this.imgDom.width, this.imgDom.height);
this.ctx.restore();
}
这里稍微解释一下 scale 函数,初次接触,可能会不太好理解。在 Canvas 中使用 scale
函数时,重要的是要理解它实际上是在缩放绘图坐标系统,而不是直接缩放绘制的图形。当你调用 ctx.scale(scaleX, scaleY)
时,你是在告诉 Canvas 之后的所有绘图操作都应该在一个被缩放的坐标系统中进行。
这意味着,如果你将缩放比例设置为 2,那么在这个缩放的坐标系统中,绘制一个宽度为 50 像素的矩形,实际上会在画布上产生一个宽度为 100 像素的矩形。因为在缩放的坐标系统中,每个单位长度都变成了原来的两倍。
因此,当我们谈论 scale
函数时,重点是要记住它是在缩放整个绘图坐标系统,而不是单独的图形。这就是为什么在使用 scale
函数后,所有的绘图操作(包括位置、大小等)都会受到影响。
(3) 鼠标按下拖拽
我们可以使用 Canvas 的 translate
方法来改变视口的位置。translate
方法接受两个参数,分别表示沿 x 轴和 y 轴移动的距离。在移动视口时,我们需要更新图片的位置,并重新绘制图像以反映新的视口位置。
// 鼠标拖动开始
private dragStart() {
this.canvas.addEventListener('mousedown', e => {
// 开始拖拽画布
this.state.isDragging = true;
this.state.dragStart.x = e.offsetX - this.state.offsetX;
this.state.dragStart.y = e.offsetY - this.state.offsetY;
this.draw();
});
}
// 鼠标拖动 move
private dragMove() {
this.canvas.addEventListener('mousemove', e => {
if (this.state.isDragging) {
this.state.offsetX = e.offsetX - this.state.dragStart.x;
this.state.offsetY = e.offsetY - this.state.dragStart.y;
}
this.draw();
});
}
// 鼠标拖动结束
private dragEnd() {
this.canvas.addEventListener('mouseup', e => {
this.state.isDragging = false;
});
}
通过记录当前拖拽的点,以此计算出拖拽之后的视口点,在重新绘制一下Canvas里面的元素即可。
(4)矩形 / 多边形绘制
矩形 / 多边形的绘制功能也相对比较简单;
矩形,需要知道左上角坐标 和 矩形的宽高,就可以绘制一个矩形;
多边形,将多边形的点连接起来就是一个多边形;(至少三个点);
这样就得同步修改鼠标按下,鼠标移动,鼠标抬起操作;
1.绘制矩形
// 鼠标拖动开始
private dragStart() {
this.canvas.addEventListener('mousedown', e => {
const imgPos = this.viewportToImage(e.offsetX, e.offsetY);
if (this.state.mode === 'rect') {
this.state.currentShape = {
id: operation.moreOperation.generateRandomString(16),
type: 'rect',
x: imgPos.x,
y: imgPos.y,
width: 0,
height: 0,
style: this.state.currentStyle
};
} else {
// 开始拖拽画布
this.setCursor('grabbing');
this.state.isDragging = true;
this.state.dragStart.x = e.offsetX - this.state.offsetX;
this.state.dragStart.y = e.offsetY - this.state.offsetY;
}
this.draw();
});
}
// 鼠标拖动 move
private dragMove() {
this.canvas.addEventListener('mousemove', e => {
if (this.state.isDragging) {
this.state.offsetX = e.offsetX - this.state.dragStart.x;
this.state.offsetY = e.offsetY - this.state.dragStart.y;
} else if (this.state.currentShape?.type === 'rect') {
const start = this.state.currentShape;
const current = this.viewportToImage(e.offsetX, e.offsetY);
start.width = current.x - start.x!;
start.height = current.y - start.y!;
}
this.draw();
});
}
// 鼠标拖动结束
private dragEnd() {
this.canvas.addEventListener('mouseup', e => {
this.state.isDragging = false;
if (this.state.currentShape) {
if (this.state.currentShape.type === 'rect') {
if (this.state.currentShape.width !== 0 && this.state.currentShape.height !== 0) {
this.state.shapes.push(this.state.currentShape);
}
this.state.currentShape = null;
}
}
});
}
2.绘制多边形
// 鼠标拖动开始
private dragStart() {
this.canvas.addEventListener('mousedown', e => {
const imgPos = this.viewportToImage(e.offsetX, e.offsetY);
if (this.state.mode === 'polygon') {
if (!this.state.currentShape) {
this.state.currentShape = {
id: operation.moreOperation.generateRandomString(16),
type: 'polygon',
points: [imgPos],
style: this.state.currentStyle
};
} else {
this.state.currentShape.points!.push(imgPos);
}
} else {
// 开始拖拽画布
this.setCursor('grabbing');
this.state.isDragging = true;
this.state.dragStart.x = e.offsetX - this.state.offsetX;
this.state.dragStart.y = e.offsetY - this.state.offsetY;
}
this.draw();
});
}
// 鼠标拖动 move
private dragMove() {
this.canvas.addEventListener('mousemove', e => {
if (this.state.isDragging) {
this.state.offsetX = e.offsetX - this.state.dragStart.x;
this.state.offsetY = e.offsetY - this.state.dragStart.y;
} else if (this.state.currentShape?.type === 'polygon') {
const current = this.viewportToImage(e.offsetX, e.offsetY);
let currentIndex = this.state.currentShape.points!.length - 1 == 0 ? 1 : this.state.currentShape.points!.length - 1;
this.state.currentShape.points![currentIndex] = current;
}
this.draw();
});
}
// 鼠标拖动结束
private dragEnd() {
this.canvas.addEventListener('mouseup', e => {
this.state.isDragging = false;
});
}
// 双击结束 - 本次 - 绘制polygon
private doubleClick(_type: 'Listening' | 'remove') {
const dbClickEndShape = () => {
if (this.state.currentShape?.type === 'polygon') {
// 移出多个 多出来的点 - 双击也同时触发了鼠标按下事件,所以需要移除这两个异常点
this.state.currentShape.points.pop();
this.state.currentShape.points.pop();
this.state.shapes.push(this.state.currentShape);
this.state.currentShape = null;
this.draw();
}
}
if (_type == 'Listening') {
this.canvas.addEventListener('dblclick', dbClickEndShape);
} else {
this.canvas.removeEventListener('dblclick', dbClickEndShape);
}
}
3. 动态绘制以绘制的图形
// 绘制操作
private draw() {
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 修改画布点位(左上角)
// 修改画布缩放
this.ctx.save();
this.ctx.translate(this.state.offsetX, this.state.offsetY);
this.ctx.scale(this.state.scale, this.state.scale);
// 重新绘制图片
// this.ctx.drawImage(this.imgDom, 0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.imgDom, 0, 0, this.imgDom.width, this.imgDom.height);
this.ctx.restore();
// 修改 - 绘制的所有形状
this.redrawStateShapes();
// 修改 - 绘制当前正在创建的形状
if (this.state.currentShape) this.redrawCurrentShape(this.state.currentShape, true);
}
// 重新绘制当前所绘制的形状
public redrawStateShapes() {
this.state.shapes.forEach(shape => {
const label = this.labels.find(item => item.styleId === shape.style.styleId)
if (label) shape.style = label
// 原生绘制操作
this.redrawCurrentShape(shape)
});
}
// 绘制当前正在创建的形状
public redrawCurrentShape(_shape: reactType | polygonType, _edit = false) {
this.ctx.save();
this.ctx.translate(this.state.offsetX, this.state.offsetY);
this.ctx.scale(this.state.scale, this.state.scale);
// 是否存在可以编辑
const _editingIsCurrent = this.state.selectedShape && _shape.id == this.state.selectedShape.id
const _color = _edit || _shape.pointer ? this.drawingColor : _shape.style.strokeStyle
const _fillColor = operation.moreOperation.colorToRgba(_color, 0.3);
this.ctx.strokeStyle = _color;
this.ctx.fillStyle = _fillColor;
this.ctx.lineWidth = 2 / this.state.scale;
const _text = `#${_shape.style.styleIndex} ${_shape.style.styleLabel}`
if (_shape.type === 'rect') {
const { x, y, width, height } = _shape;
this.ctx.fillRect(x, y, width, height); // 填充矩形
this.ctx.strokeRect(x, y, width, height);
// 绘制标签
if (!_edit && !_editingIsCurrent) this.redrawLabel(x + width / 2, y + height / 2, _text, _color);
} else if (_shape.type === 'polygon') {
this.ctx.beginPath();
_shape.points.forEach((p: { x: number; y: number }, i: number) => {
if (i === 0) this.ctx.moveTo(p.x, p.y);
else this.ctx.lineTo(p.x, p.y);
});
if (!_edit) this.ctx.closePath();
this.ctx.fill(); // 填充多边形
this.ctx.stroke();
if (!_editingIsCurrent) {
if (!_edit) {
// 绘制标签
const centerX = _shape.points.reduce((sum, p) => sum + p.x, 0) / _shape.points.length;
const centerY = _shape.points.reduce((sum, p) => sum + p.y, 0) / _shape.points.length;
this.redrawLabel(centerX, centerY, _text, _color);
} else {
// 编辑模式下提示双击结束编辑
this.redrawLabel(_shape.points[_shape.points.length - 1].x + 60, _shape.points[_shape.points.length - 1].y - 15, '双击结束编辑', 'rgba(255, 255, 255, 0.7)', 'black');
}
}
}
this.ctx.restore();
}
// 绘制标签
public redrawLabel(x: number, y: number, text: string, backgroundColor: string = 'rgba(255, 255, 255, 0.7)', textColor: string = 'black', borderRadius: number = 5) {
const padding = 5; // 标签内边距
const fontSize = 16; // 字体大小
// 设置字体样式
this.ctx.font = `${fontSize}px Arial`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
// 计算文本宽度和高度
const textWidth = this.ctx.measureText(text).width;
const textHeight = fontSize; // 简单估计文本高度
// 绘制背景矩形(圆角)
this.ctx.fillStyle = backgroundColor;
this.ctx.beginPath();
this.ctx.moveTo(x - textWidth / 2 - padding + borderRadius, y - textHeight / 2 - padding);
this.ctx.lineTo(x + textWidth / 2 + padding - borderRadius, y - textHeight / 2 - padding);
this.ctx.quadraticCurveTo(x + textWidth / 2 + padding, y - textHeight / 2 - padding, x + textWidth / 2 + padding, y - textHeight / 2 - padding + borderRadius);
this.ctx.lineTo(x + textWidth / 2 + padding, y + textHeight / 2 + padding - borderRadius);
this.ctx.quadraticCurveTo(x + textWidth / 2 + padding, y + textHeight / 2 + padding, x + textWidth / 2 + padding - borderRadius, y + textHeight / 2 + padding);
this.ctx.lineTo(x - textWidth / 2 - padding + borderRadius, y + textHeight / 2 + padding);
this.ctx.quadraticCurveTo(x - textWidth / 2 - padding, y + textHeight / 2 + padding, x - textWidth / 2 - padding, y + textHeight / 2 + padding - borderRadius);
this.ctx.lineTo(x - textWidth / 2 - padding, y - textHeight / 2 - padding + borderRadius);
this.ctx.quadraticCurveTo(x - textWidth / 2 - padding, y - textHeight / 2 - padding, x - textWidth / 2 - padding + borderRadius, y - textHeight / 2 - padding);
this.ctx.closePath();
this.ctx.fill();
// 绘制文本
this.ctx.fillStyle = textColor;
this.ctx.fillText(text, x, y);
}
(5)矩形 / 多边形编辑
矩形 / 多边形编辑相对比较复杂;首先需要明确编辑点,拖拽修改编辑点修改原有图形内的属性;
判断是否选中图形
// 判断鼠标当前的位置是否在已经绘制的形状中
private isPointInShape(imgPos: { x: number, y: number }) {
// 检测是否选中形状
for (let i = 0; i < this.state.shapes.length; i++) {
const shape = this.state.shapes[i];
const _selectShape = this.state.selectedShape && shape.id == this.state.selectedShape.id
const _currentCursor = _selectShape ? 'all-scroll' : 'pointer'
// 设置指针
shape.pointer = false;
// 简单矩形碰撞检测
if (shape.type === 'rect' &&
imgPos.x >= shape.x && imgPos.x <= shape.x + shape.width &&
imgPos.y >= shape.y && imgPos.y <= shape.y + shape.height) {
this.setCursor(_currentCursor)
shape.pointer = true;
return shape;
} else if (shape.type === 'polygon') {
const points = shape.points;
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x, yi = points[i].y;
const xj = points[j].x, yj = points[j].y;
const intersect = ((yi > imgPos.y) !== (yj > imgPos.y)) &&
(imgPos.x < (xj - xi) * (imgPos.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
if (inside) {
this.setCursor(_currentCursor);
shape.pointer = true;
return shape;
}
}
}
this.changeCursor()
return undefined;
}
先修改鼠标按下、移动、抬起逻辑(实现图形编辑状态、可整体拖拽)
// 鼠标拖动开始
private dragStart() {
this.canvas.addEventListener('mousedown', e => {
// 检测是否选中形状
this.state.selectedShape = null;
const _selectShape = this.isPointInShape(imgPos);
if (_selectShape) {
this.state.selectedShape = _selectShape;
this.currentAllScroll = true
// 计算坐标差
if (_selectShape.type == 'rect') {
this.draggingRect[0].x = imgPos.x - _selectShape.x;
this.draggingRect[0].y = imgPos.y - _selectShape.y;
} else {
// 记录拖拽时,每一个点的原始位置
_selectShape.points.forEach((point, index) => {
const _point = this.draggingRect[index]
const _x = imgPos.x - point.x;
const _y = imgPos.y - point.y;
if (_point) {
_point.x = _x;
_point.y = _y;
} else {
this.draggingRect.push({
x: _x,
y: _y
})
}
})
}
this.draw();
});
}
// 鼠标拖动 move
private dragMove() {
this.canvas.addEventListener('mousemove', e => {
if (!this.state.currentShape) {
// 不是拖拽情况下时,判断是否move到某个已经绘制好的标注上面
const imgPos = this.viewportToImage(e.offsetX, e.offsetY);
const _selectShape = this.isPointInShape(imgPos)
// 判断是否选中 编辑形状
if (this.state.selectedShape) {
if (this.currentAllScroll) {
if (this.state.selectedShape.type === 'rect') {
this.state.selectedShape.x = imgPos.x - this.draggingRect[0].x;
this.state.selectedShape.y = imgPos.y - this.draggingRect[0].y;
} else {
// 更新多边形的每个点的位置
this.state.selectedShape.points.forEach((point, index) => {
point.x = imgPos.x - this.draggingRect[index].x;
point.y = imgPos.y - this.draggingRect[index].y;
});
}
}
}
}
this.draw();
});
}
// 鼠标拖动结束
private dragEnd() {
this.canvas.addEventListener('mouseup', e => {
this.editableDragging = false;
});
}
绘制编辑状态的图形
// 编辑图形的相应操作
public editShape(shape: reactType | polygonType) {
this.coordinates = {
'ns-resize': [],
'ew-resize': [],
'nwse-resize': [],
'nesw-resize': [],
'crosshair': [],
}
let _points = [];
if (shape.type === 'rect') {
_points = this.getRectangleCorners(shape.x, shape.y, shape.width, shape.height, true);
this.coordinates['ns-resize'].push(_points[4], _points[6]);
this.coordinates['ew-resize'].push(_points[5], _points[7]);
this.coordinates['nwse-resize'].push(_points[0], _points[2]);
this.coordinates['nesw-resize'].push(_points[1], _points[3]);
} else {
_points = shape.points;
this.coordinates['crosshair'] = _points;
}
// 绘制可编辑点
_points.forEach(p => {
// 绘制边框和填充
this.ctx.fillStyle = this.editingColor.background; // 设置填充颜色
this.ctx.strokeStyle = this.editingColor.border; // 设置边框颜色
this.ctx.lineWidth = 2; // 设置边框宽度
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
this.ctx.fill(); // 填充颜色
this.ctx.stroke(); // 绘制边框
});
}
1. 矩形编辑
实现逻辑:通过矩形的x,y,width,height计算获取矩形可编辑点(左上、左下、右上、右下、上中、下中、左中、右中)八个可编辑的点,明确每个点的修改逻辑
左上、左下、右上、右下:x,y,width,height 均被修改
上中、下中:y,height 被修改
左中、右中:x,width被修改
标记当前拖拽的是哪个点,根据不同点,不同逻辑实现拖拽操作;
(代码过于长,实现逻辑如上,具体代码,请查阅代码概览)
2. 多边形编辑
实现逻辑:拖拽并且同时修改某个顶点坐标、重新绘制即可;
三、代码概览
这是最原始的实现方式,当然你也可以借助Canvas的工具库进行更加简易的实现方式。(比如:fabric.js)
// 结合 npm install fabric --save (fabric.js) 实现对其编辑操作
// import * as fabric from 'fabric';
// 导入工具类函数
import operation from '@/utils/basicFunctionalFunction';
interface ImageOption {
src: string
width?: number
height?: number
top?: number
left?: number
}
interface styleType {
styleId: string
strokeStyle: string
styleIndex: number
styleLabel: string
}
interface reactType {
id: string
type: 'rect'
x: number
y: number
width: number
height: number
style: styleType
pointer: boolean
}
interface polygonType {
id: string
type: 'polygon'
points: { x: number, y: number }[]
style: styleType
pointer: boolean
}
interface drawState {
scale: number // 缩放
offsetX: number // 偏移X
offsetY: number // 偏移Y
isDragging: boolean // 是否拖拽
dragStart: { x: number, y: number } // 拖拽开始位置
shapes: (reactType | polygonType)[] // 所有形状
currentShape: reactType | polygonType | null | any, // 当前绘制的形状
mode: 'rect' | 'polygon' | null, // 'rect', 'polygon' // 当前绘制模式
selectedShape: reactType | polygonType | null // 当前选中的形状
currentStyle: styleType
}
class CanvasImage {
private canvas: HTMLCanvasElement; // 当前画布
private ctx: CanvasRenderingContext2D; // 当前画布内容
private imgDom: HTMLImageElement; // 渲染的图片
private state: drawState; // 画布状态
// private fabricCanvas: fabric.Canvas; // fabric画布
public labels: styleType[] = [] // 标注样式
public drawingColor: string = '#fff'
public editingColor: {
border: string
background: string
} = {
border: '#000',
background: '#fff'
}
public currentAllScroll: boolean = false
public draggingRect: { x: number, y: number }[] = [{ x: 0, y: 0 }]
public coordinates: {
'ns-resize': { x: number, y: number }[],
'ew-resize': { x: number, y: number }[],
'nwse-resize': { x: number, y: number }[],
'nesw-resize': { x: number, y: number }[],
'crosshair': { x: number, y: number }[],
} = {
'ns-resize': [],
'ew-resize': [],
'nwse-resize': [],
'nesw-resize': [],
'crosshair': [],
}
public editableDragging: boolean = false // 可编辑状态下的拖拽状态
public lastEditablePoint: { type: 'ns-resize' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'crosshair', index: number } | null = null
// 矩形拖拽时,需要记录当前拖拽的图形的左上角和宽高
public rectInfo: {
x: number
y: number
width: number
height: number
} = {
x: 0,
y: 0,
width: 0,
height: 0,
}
constructor(_domCanvas: HTMLCanvasElement, _dom: HTMLDivElement, _imageOption?: ImageOption) {
// this.fabricCanvas = new fabric.Canvas(_domCanvas);
// console.log(this.fabricCanvas)
// this.canvas = this.fabricCanvas.elements.lower.el
this.canvas = _domCanvas;
this.ctx = _domCanvas.getContext('2d')!;
// 初始化图片
this.imgDom = new Image();
// 初始化状态
this.state = {
scale: 1,
// offsetX: _dom.offsetLeft,
// offsetY: _dom.offsetTop,
offsetX: 0,
offsetY: 0,
isDragging: false,
dragStart: { x: 0, y: 0 },
shapes: [],
currentShape: null,
mode: null, // 'rect', 'polygon'
selectedShape: null,
currentStyle: { // '#95989e'
styleId: 'string',
strokeStyle: '#95989e',
styleIndex: 1,
styleLabel: ''
}
};
// 初始化画布
this.initCanvas(_dom);
// 加载图片
if (_imageOption) this.loadImage(_imageOption.src, _imageOption.width, _imageOption.height, _imageOption.top, _imageOption.left);
}
// 初始化画布
public initCanvas(_div: HTMLDivElement) {
// 监听大小变化
this.resizeCanvas(_div)
// 鼠标滚动缩放
this.zoomCanvas()
// 鼠标拖动
this.dragCanvas()
// 默认绘制逻辑为 null
this.setMode(null);
}
// 修改当前绘制逻辑
public setMode(mode: 'rect' | 'polygon' | null) {
// polygon 绘制状态时,加入双击结束编辑操作
mode == 'polygon' ? this.doubleClick('Listening') : this.doubleClick('remove');
this.state.mode = mode;
this.state.currentShape = null;
this.changeCursor()
}
// 设置当前绘制样式
public setStyle(style: styleType) {
this.state.currentStyle = style;
// this.state.currentStrokeStyle = style.currentStrokeStyle;
}
// 设置标注样式
public setLabels(labels: styleType[]) {
this.labels = labels;
}
// 设置当前canvas的鼠标样式
public setCursor(cursor: 'grabbing' | 'grab' | 'pointer' | 'crosshair' | 'all-scroll' | 'e-resize' | 'n-resize' | 'w-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'nw-resize' | 'ne-resize' | 'ns-resize' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'col-resize' | 'row-resize') {
this.canvas.style.cursor = cursor;
}
// 根据mode改变鼠标样式
private changeCursor() {
if (this.state.mode == null) {
this.setCursor('grab')
} else if (this.state.mode == 'rect') {
this.setCursor('crosshair')
} else if (this.state.mode == 'polygon') {
this.setCursor('crosshair')
}
}
// 绘制操作
private draw() {
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 修改画布点位(左上角)
// 修改画布缩放
this.ctx.save();
this.ctx.translate(this.state.offsetX, this.state.offsetY);
this.ctx.scale(this.state.scale, this.state.scale);
// 重新绘制图片
// this.ctx.drawImage(this.imgDom, 0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.imgDom, 0, 0, this.imgDom.width, this.imgDom.height);
this.ctx.restore();
// 修改 - 绘制的所有形状
this.redrawStateShapes();
// 修改 - 绘制当前正在创建的形状
if (this.state.currentShape) this.redrawCurrentShape(this.state.currentShape, true);
}
// 重新绘制当前所绘制的形状
public redrawStateShapes() {
this.state.shapes.forEach(shape => {
const label = this.labels.find(item => item.styleId === shape.style.styleId)
if (label) shape.style = label
// 原生绘制操作
this.redrawCurrentShape(shape)
// 替换原生操作 - 使用工具库 - 当前已经完成使用fabric重新绘制
// if (shape.type === 'rect') {
// const { x, y, width, height } = shape;
// const rect = new fabric.Rect({
// left: x,
// top: y,
// fill: 'red',
// width,
// height,
// })
// this.fabricCanvas.add(rect);
// } else {
// }
});
}
// 绘制当前正在创建的形状
public redrawCurrentShape(_shape: reactType | polygonType, _edit = false) {
this.ctx.save();
this.ctx.translate(this.state.offsetX, this.state.offsetY);
this.ctx.scale(this.state.scale, this.state.scale);
// 是否存在可以编辑
const _editingIsCurrent = this.state.selectedShape && _shape.id == this.state.selectedShape.id
const _color = _edit || _shape.pointer ? this.drawingColor : _shape.style.strokeStyle
const _fillColor = operation.moreOperation.colorToRgba(_color, 0.3);
this.ctx.strokeStyle = _color;
this.ctx.fillStyle = _fillColor;
this.ctx.lineWidth = 2 / this.state.scale;
const _text = `#${_shape.style.styleIndex} ${_shape.style.styleLabel}`
if (_shape.type === 'rect') {
const { x, y, width, height } = _shape;
this.ctx.fillRect(x, y, width, height); // 填充矩形
this.ctx.strokeRect(x, y, width, height);
// 绘制标签
if (!_edit && !_editingIsCurrent) this.redrawLabel(x + width / 2, y + height / 2, _text, _color);
} else if (_shape.type === 'polygon') {
this.ctx.beginPath();
_shape.points.forEach((p: { x: number; y: number }, i: number) => {
if (i === 0) this.ctx.moveTo(p.x, p.y);
else this.ctx.lineTo(p.x, p.y);
});
if (!_edit) this.ctx.closePath();
this.ctx.fill(); // 填充多边形
this.ctx.stroke();
if (!_editingIsCurrent) {
if (!_edit) {
// 绘制标签
const centerX = _shape.points.reduce((sum, p) => sum + p.x, 0) / _shape.points.length;
const centerY = _shape.points.reduce((sum, p) => sum + p.y, 0) / _shape.points.length;
this.redrawLabel(centerX, centerY, _text, _color);
} else {
// 编辑模式下提示双击结束编辑
this.redrawLabel(_shape.points[_shape.points.length - 1].x + 60, _shape.points[_shape.points.length - 1].y - 15, '双击结束编辑', 'rgba(255, 255, 255, 0.7)', 'black');
}
}
}
if (_editingIsCurrent) this.editShape(_shape);
this.ctx.restore();
}
// 绘制标签
public redrawLabel(x: number, y: number, text: string, backgroundColor: string = 'rgba(255, 255, 255, 0.7)', textColor: string = 'black', borderRadius: number = 5) {
const padding = 5; // 标签内边距
const fontSize = 16; // 字体大小
// 设置字体样式
this.ctx.font = `${fontSize}px Arial`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
// 计算文本宽度和高度
const textWidth = this.ctx.measureText(text).width;
const textHeight = fontSize; // 简单估计文本高度
// 绘制背景矩形(圆角)
this.ctx.fillStyle = backgroundColor;
this.ctx.beginPath();
this.ctx.moveTo(x - textWidth / 2 - padding + borderRadius, y - textHeight / 2 - padding);
this.ctx.lineTo(x + textWidth / 2 + padding - borderRadius, y - textHeight / 2 - padding);
this.ctx.quadraticCurveTo(x + textWidth / 2 + padding, y - textHeight / 2 - padding, x + textWidth / 2 + padding, y - textHeight / 2 - padding + borderRadius);
this.ctx.lineTo(x + textWidth / 2 + padding, y + textHeight / 2 + padding - borderRadius);
this.ctx.quadraticCurveTo(x + textWidth / 2 + padding, y + textHeight / 2 + padding, x + textWidth / 2 + padding - borderRadius, y + textHeight / 2 + padding);
this.ctx.lineTo(x - textWidth / 2 - padding + borderRadius, y + textHeight / 2 + padding);
this.ctx.quadraticCurveTo(x - textWidth / 2 - padding, y + textHeight / 2 + padding, x - textWidth / 2 - padding, y + textHeight / 2 + padding - borderRadius);
this.ctx.lineTo(x - textWidth / 2 - padding, y - textHeight / 2 - padding + borderRadius);
this.ctx.quadraticCurveTo(x - textWidth / 2 - padding, y - textHeight / 2 - padding, x - textWidth / 2 - padding + borderRadius, y - textHeight / 2 - padding);
this.ctx.closePath();
this.ctx.fill();
// 绘制文本
this.ctx.fillStyle = textColor;
this.ctx.fillText(text, x, y);
}
// 编辑图形的相应操作
public editShape(shape: reactType | polygonType) {
this.coordinates = {
'ns-resize': [],
'ew-resize': [],
'nwse-resize': [],
'nesw-resize': [],
'crosshair': [],
}
let _points = [];
if (shape.type === 'rect') {
_points = this.getRectangleCorners(shape.x, shape.y, shape.width, shape.height, true);
this.coordinates['ns-resize'].push(_points[4], _points[6]);
this.coordinates['ew-resize'].push(_points[5], _points[7]);
this.coordinates['nwse-resize'].push(_points[0], _points[2]);
this.coordinates['nesw-resize'].push(_points[1], _points[3]);
} else {
_points = shape.points;
this.coordinates['crosshair'] = _points;
}
// 绘制可编辑点
_points.forEach(p => {
// 绘制边框和填充
this.ctx.fillStyle = this.editingColor.background; // 设置填充颜色
this.ctx.strokeStyle = this.editingColor.border; // 设置边框颜色
this.ctx.lineWidth = 2; // 设置边框宽度
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
this.ctx.fill(); // 填充颜色
this.ctx.stroke(); // 绘制边框
});
}
// 清空图片
public clearImageLoad() {
this.imgDom = new Image();
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
// 手动加载图片
public loadImage(_imageSrc: string, width = 0, height = 0, top = 0, left = 0) {
this.imgDom.src = _imageSrc;
this.imgDom.onload = () => {
width = width || this.imgDom.width
height = height || this.imgDom.height
this.ctx.drawImage(this.imgDom, top, left, width, height);
};
}
// 设置Canvas大小
private resizeCanvas(_div: HTMLDivElement) {
this.canvas.width = _div.clientWidth;
this.canvas.height = _div.clientHeight;
window.addEventListener('resize', () => this.resizeCanvas(_div));
}
// 缩放处理
private zoomCanvas() {
this.canvas.addEventListener('wheel', e => {
e.preventDefault();
const zoomIntensity = 0.1;
const delta = e.deltaY < 0 ? 1 : -1;
const newScale = delta > 0 ? this.state.scale * (1 + zoomIntensity) : this.state.scale / (1 + zoomIntensity);
// 以鼠标位置为中心缩放
const mouseX = e.offsetX;
const mouseY = e.offsetY;
const imgPos = this.viewportToImage(mouseX, mouseY);
this.state.scale = newScale;
this.state.offsetX = mouseX - imgPos.x * this.state.scale;
this.state.offsetY = mouseY - imgPos.y * this.state.scale;
console.log(this.state)
this.draw();
}, { passive: false });
}
private dragCanvas() {
// 鼠标按下
this.dragStart();
// 鼠标移动
this.dragMove();
// 鼠标抬起
this.dragEnd();
// 鼠标右键
this.rightClick();
}
// 鼠标拖动开始
private dragStart() {
this.canvas.addEventListener('mousedown', e => {
if (e.button === 2) {
// 右键点击
console.log("右键点击");
// 处理右键点击的逻辑
return
}
const imgPos = this.viewportToImage(e.offsetX, e.offsetY);
if (this.state.mode === 'rect') {
this.state.currentShape = {
id: operation.moreOperation.generateRandomString(16),
type: 'rect',
x: imgPos.x,
y: imgPos.y,
width: 0,
height: 0,
style: this.state.currentStyle
};
} else if (this.state.mode === 'polygon') {
if (!this.state.currentShape) {
this.state.currentShape = {
id: operation.moreOperation.generateRandomString(16),
type: 'polygon',
points: [imgPos],
style: this.state.currentStyle
};
} else {
this.state.currentShape.points!.push(imgPos);
}
} else {
// 检测是否选中形状
let _activeSelectShapeId = this.state.selectedShape?.id
this.state.selectedShape = null;
const _editablePoint = this.isPointInEditablePoints(imgPos);
const _selectShape = this.isPointInShape(imgPos);
if (_editablePoint) {
this.setActiveShape(_activeSelectShapeId!)
this.setCursor(_editablePoint.type)
this.editableDragging = true
this.lastEditablePoint = _editablePoint
if (this.state.selectedShape!.type == 'rect') {
const _shape: any = JSON.parse(JSON.stringify(this.state.selectedShape))
this.rectInfo = _shape
}
} else if (_selectShape) {
this.state.selectedShape = _selectShape;
this.currentAllScroll = true
// 计算坐标差
if (_selectShape.type == 'rect') {
this.draggingRect[0].x = imgPos.x - _selectShape.x;
this.draggingRect[0].y = imgPos.y - _selectShape.y;
} else {
// 记录拖拽时,每一个点的原始位置
_selectShape.points.forEach((point, index) => {
const _point = this.draggingRect[index]
const _x = imgPos.x - point.x;
const _y = imgPos.y - point.y;
if (_point) {
_point.x = _x;
_point.y = _y;
} else {
this.draggingRect.push({
x: _x,
y: _y
})
}
})
}
} else {
// 开始拖拽画布
this.setCursor('grabbing');
this.state.isDragging = true;
this.state.dragStart.x = e.offsetX - this.state.offsetX;
this.state.dragStart.y = e.offsetY - this.state.offsetY;
}
}
this.draw();
});
}
// 鼠标拖动 move
private dragMove() {
this.canvas.addEventListener('mousemove', e => {
if (this.state.isDragging) {
this.state.offsetX = e.offsetX - this.state.dragStart.x;
this.state.offsetY = e.offsetY - this.state.dragStart.y;
} else if (!this.state.currentShape) {
// 检测是否选中形状
let _activeSelectShapeId = this.state.selectedShape?.id
// 不是拖拽情况下时,判断是否move到某个已经绘制好的标注上面
const imgPos = this.viewportToImage(e.offsetX, e.offsetY);
if (this.editableDragging && this.lastEditablePoint) {
if (this.state.selectedShape?.type == 'rect') {
const { x, y, width, height } = this.adjustRectangle(
this.rectInfo.x,
this.rectInfo.y,
this.rectInfo.width,
this.rectInfo.height,
this.setCurrentPointIng(),
imgPos.x,
imgPos.y
)
// 重新计算矩形的 x, y, width, height
this.state.selectedShape.x = x;
this.state.selectedShape.y = y;
this.state.selectedShape.width = width;
this.state.selectedShape.height = height;
} else {
this.coordinates[this.lastEditablePoint.type][this.lastEditablePoint.index].x = imgPos.x
this.coordinates[this.lastEditablePoint.type][this.lastEditablePoint.index].y = imgPos.y
}
} else {
const _editablePoint = this.isPointInEditablePoints(imgPos);
const _selectShape = this.isPointInShape(imgPos)
// 是否选中 编辑形状
if (_editablePoint) {
this.setActiveShape(_activeSelectShapeId!)
this.setCursor(_editablePoint.type)
// this.lastEditablePoint = _editablePoint
}
// 判断是否选中 编辑形状
if (this.state.selectedShape) {
if (this.currentAllScroll) {
if (this.state.selectedShape.type === 'rect') {
this.state.selectedShape.x = imgPos.x - this.draggingRect[0].x;
this.state.selectedShape.y = imgPos.y - this.draggingRect[0].y;
} else {
// 更新多边形的每个点的位置
this.state.selectedShape.points.forEach((point, index) => {
point.x = imgPos.x - this.draggingRect[index].x;
point.y = imgPos.y - this.draggingRect[index].y;
});
}
}
}
}
} else if (this.state.currentShape?.type === 'rect') {
const start = this.state.currentShape;
const current = this.viewportToImage(e.offsetX, e.offsetY);
start.width = current.x - start.x!;
start.height = current.y - start.y!;
} else if (this.state.currentShape?.type === 'polygon') {
const current = this.viewportToImage(e.offsetX, e.offsetY);
let currentIndex = this.state.currentShape.points!.length - 1 == 0 ? 1 : this.state.currentShape.points!.length - 1;
this.state.currentShape.points![currentIndex] = current;
}
this.draw();
});
}
// 鼠标拖动结束
private dragEnd() {
this.canvas.addEventListener('mouseup', e => {
this.state.isDragging = false;
this.editableDragging = false;
// 停止拖拽 图形
this.currentAllScroll = false
this.changeCursor()
if (this.state.currentShape) {
if (this.state.currentShape.type === 'rect') {
if (this.state.currentShape.width !== 0 && this.state.currentShape.height !== 0) {
this.state.shapes.push(this.state.currentShape);
}
this.state.currentShape = null;
}
}
});
}
// 双击结束 - 本次 - 绘制polygon
private doubleClick(_type: 'Listening' | 'remove') {
const dbClickEndShape = () => {
if (this.state.currentShape?.type === 'polygon') {
// 移出多个 多出来的点
this.state.currentShape.points.pop();
this.state.currentShape.points.pop();
this.state.shapes.push(this.state.currentShape);
this.state.currentShape = null;
this.draw();
}
// this.state.mode = null;
// this.setMode('polygon')
}
if (_type == 'Listening') {
this.canvas.addEventListener('dblclick', dbClickEndShape);
} else {
this.canvas.removeEventListener('dblclick', dbClickEndShape);
}
}
// 鼠标右键
private rightClick() {
this.canvas.addEventListener('contextmenu', e => {
e.preventDefault();
})
}
// 拖拽修改矩形修改点之后,针对修改之后的操作,计算最新的矩形坐标
private adjustRectangle(originalX: number, originalY: number, originalWidth: number, originalHeight: number, draggedPointIndex: number, newX: number, newY: number) {
let x = originalX;
let y = originalY;
let width = originalWidth;
let height = originalHeight;
// 计算当前矩形的四个角点
const currentRight = originalX + originalWidth;
const currentBottom = originalY + originalHeight;
const updateCorner = (oppositeX: number, oppositeY: number) => {
// 动态计算新矩形坐标和尺寸
const newLeft = Math.min(newX, oppositeX);
const newTop = Math.min(newY, oppositeY);
const newRight = Math.max(newX, oppositeX);
const newBottom = Math.max(newY, oppositeY);
x = newLeft;
y = newTop;
width = newRight - newLeft;
height = newBottom - newTop;
}
const updateTopEdge = () => {
const bottomY = originalY + originalHeight;
const newHeight = bottomY - newY;
if (newHeight < 0) {
y = bottomY;
height = -newHeight;
} else {
y = newY;
height = newHeight;
}
width = originalWidth;
x = originalX;
}
const updateBottomEdge = () => {
const newHeight = newY - originalY;
if (newHeight < 0) {
y = newY;
height = -newHeight;
} else {
height = newHeight;
}
width = originalWidth;
x = originalX;
}
const updateRightEdge = () => {
const newWidth = newX - originalX;
if (newWidth < 0) {
x = newX;
width = -newWidth;
} else {
width = newWidth;
}
height = originalHeight;
y = originalY;
}
const updateLeftEdge = () => {
const rightX = originalX + originalWidth;
const newWidth = rightX - newX;
if (newWidth < 0) {
x = rightX;
width = -newWidth;
} else {
x = newX;
width = newWidth;
}
height = originalHeight;
y = originalY;
}
switch (draggedPointIndex) {
// 角点处理
case 0: // 左上角
updateCorner(currentRight, currentBottom); // 使用当前右下角作为对顶点
break;
case 2: // 右上角
updateCorner(originalX, currentBottom);
break;
case 4: // 右下角
updateCorner(originalX, originalY);
break;
case 6: // 左下角
updateCorner(currentRight, originalY);
break;
// 边中点处理
case 1: // 上边中点
updateTopEdge();
break;
case 5: // 下边中点
updateBottomEdge();
break;
case 3: // 右边中点
updateRightEdge();
break;
case 7: // 左边中点
updateLeftEdge();
break;
default:
throw new Error('Invalid point index');
}
return { x, y, width, height };
}
// 设置当前 的是哪个点
private setCurrentPointIng() {
// this.lastEditablePoint.type this.lastEditablePoint.index
if (this.lastEditablePoint!.type == 'ew-resize') {
if (this.lastEditablePoint!.index == 0) {
return 7
} else if (this.lastEditablePoint!.index == 1) return 3
} else if (this.lastEditablePoint!.type == 'ns-resize') {
if (this.lastEditablePoint!.index == 0) {
return 1
} else if (this.lastEditablePoint!.index == 1) return 5
} else if (this.lastEditablePoint!.type == 'nwse-resize') {
if (this.lastEditablePoint!.index == 0) {
return 0
} else if (this.lastEditablePoint!.index == 1) return 4
} else if (this.lastEditablePoint!.type == 'nesw-resize') {
if (this.lastEditablePoint!.index == 0) {
return 2
} else if (this.lastEditablePoint!.index == 1) return 6
}
return 0
}
// 通过矩形左上角x、y、width、height获得矩形的四个坐标
private getRectangleCorners(x: number, y: number, width: number, height: number, _edit = false): { x: number, y: number }[] {
const _points = [
{ x: x, y: y }, // 左上角
{ x: x + width, y: y }, // 右上角
{ x: x + width, y: y + height }, // 右下角
{ x: x, y: y + height }, // 左下角
]
if (_edit) {
_points.push(...[
{ x: x + width / 2, y: y }, // 上边中点
{ x: x, y: y + height / 2 }, // 左边中点
{ x: x + width / 2, y: y + height }, // 下边中点
{ x: x + width, y: y + height / 2 } // 右边中点
])
}
return _points;
}
// 坐标转换:视口坐标 -> 图片坐标
private viewportToImage(x: number, y: number) {
return {
x: (x - this.state.offsetX) / this.state.scale,
y: (y - this.state.offsetY) / this.state.scale
};
}
// 坐标转换:图片坐标 -> 视口坐标
private imageToViewport(x: number, y: number) {
return {
x: x * this.state.scale + this.state.offsetX,
y: y * this.state.scale + this.state.offsetY
};
}
// 判断鼠标当前的位置是否在已经绘制的形状中
private isPointInShape(imgPos: { x: number, y: number }) {
// 检测是否选中形状
for (let i = 0; i < this.state.shapes.length; i++) {
const shape = this.state.shapes[i];
const _selectShape = this.state.selectedShape && shape.id == this.state.selectedShape.id
const _currentCursor = _selectShape ? 'all-scroll' : 'pointer'
// 设置指针
shape.pointer = false;
// 简单矩形碰撞检测
if (shape.type === 'rect' &&
imgPos.x >= shape.x && imgPos.x <= shape.x + shape.width &&
imgPos.y >= shape.y && imgPos.y <= shape.y + shape.height) {
this.setCursor(_currentCursor)
shape.pointer = true;
return shape;
} else if (shape.type === 'polygon') {
const points = shape.points;
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x, yi = points[i].y;
const xj = points[j].x, yj = points[j].y;
const intersect = ((yi > imgPos.y) !== (yj > imgPos.y)) &&
(imgPos.x < (xj - xi) * (imgPos.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
if (inside) {
this.setCursor(_currentCursor);
shape.pointer = true;
return shape;
}
}
}
this.changeCursor()
return undefined;
}
// 判断鼠标按下的地方是否在图片内
private isPointInImage(imgPos: { x: number, y: number }) {
const imgX = this.imgDom.offsetLeft; // 图片的左上角 x 坐标
const imgY = this.imgDom.offsetTop; // 图片的左上角 y 坐标
const imgWidth = this.imgDom.width; // 图片的宽度
const imgHeight = this.imgDom.height; // 图片的高度
return imgPos.x >= imgX && imgPos.x <= imgX + imgWidth &&
imgPos.y >= imgY && imgPos.y <= imgY + imgHeight;
}
// 检查鼠标是否在可编辑点内
private isPointInEditablePoints(mousePos: { x: number, y: number }): {
type: 'ns-resize' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'crosshair'
index: number
} | undefined {
const _isPoint = (dx: number, dy: number) => {
const radius = 5; // 可编辑点的半径
return Math.sqrt(dx * dx + dy * dy) <= radius
}
for (const key of Object.keys(this.coordinates) as ('ns-resize' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'crosshair')[]) {
const _points = this.coordinates[key];
for (let i = 0; i < _points.length; i++) {
const point = _points[i];
const dx = mousePos.x - point.x;
const dy = mousePos.y - point.y;
const _flag = _isPoint(dx, dy)
if (_flag) {
return { type: key, index: i }
}
}
}
return undefined
}
// 设置某个图形 激活
private setActiveShape(shapeId: string) {
for (let i = 0; i < this.state.shapes.length; i++) { // 遍历所有图形
const _shape = this.state.shapes[i];
if (_shape.id == shapeId) { // 找到对应的图形
this.state.selectedShape = _shape; // 设置为激活状态
_shape.pointer = true
break;
}
}
}
}
export default CanvasImage