前端vue使用canvas封装图片标注功能,鼠标画矩形框,标注文字 包含下载标注之后的图片
先看效果:
前言:接到需求看到了很多第三方的插件,都挺不错的,tui-image-editor 使用的这个插件,唯一的缺点就是自定义比较麻烦,所以只能自己封装了
1.在父组件里面引入
<assistData :imageUrl="alarmOriginalImageLocal" :text-config="textConfig" ref="editorRef">
需要传标注的文字 及图片
alarmLabeledImageLocal : 图片地址
2.封装的组件
<template>
<div class="annotator-container">
<canvas
ref="canvasRef"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
:style="{ cursor: canvasCursor }"
class="canvas"
></canvas>
<div class="control-buttons">
<button @click="clearAllRects" style="margin-left: 10px; background: #ff4444;">清除所有</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, defineProps, defineExpose } from 'vue';
import { getToken } from '@/utils/auth'
import { useStore } from 'vuex';
const store = useStore();
const props = defineProps({
imageUrl: {
type: String,
default: ""
},
textConfig: {
type: Object,
default: {}
},
})
// 获取 canvas 引用
const canvasRef = ref(null);
// 图片对象
const image = ref(new Image());
// 标注矩形列表(核心状态)
const rects = ref([]);
// 当前操作的矩形索引
const activeRectIndex = ref(-1);
const operationType = ref(null);
const startPos = ref({ x: 0, y: 0 });
const initialRect = ref({ x: 0, y: 0, w: 0, h: 0 });
// 绘制状态
const isDrawingNewRect = ref(false);
const newRectStart = ref({ x: 0, y: 0 });
const newRectCurrent = ref({ x: 0, y: 0 });
// 设备像素比(用于坐标校准)
const dpr = ref(window.devicePixelRatio || 1);
// 画布鼠标样式
const canvasCursor = ref('default');
// 默认矩形配置
const RECT_CONFIG = {
strokeColor: '#ff3b30',
strokeWidth: 2,
fillColor: 'transparent', // 填充颜色透明度
handleSize: 8, // 缩放控制点尺寸
handleColor: '#ffffff'
};
// 合并文字配置(优先使用父组件配置)
const mergedTextConfig = ref({ ...props.textConfig });
// 初始化加载图片
onMounted(() => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = props.imageUrl;
img.onload = () => {
image.value = img;
resizeCanvas(0.8);
};
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
// 坐标转换:将鼠标客户端坐标转换为画布逻辑坐标
function getCanvasPoint(clientX, clientY) {
const canvas = canvasRef.value;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
// 调整 canvas 尺寸(适配图片大小)
const resizeCanvas = (scale = 1) => {
if (!canvasRef.value || !image.value) return
// 1. 计算缩放后的宽高
const originalWidth = image.value.width
const originalHeight = image.value.height
const scaledWidth = originalWidth * scale
const scaledHeight = originalHeight * scale
// 2. 适配高清屏幕(devicePixelRatio)
dpr.value = window.devicePixelRatio || 1
canvasRef.value.width = scaledWidth * dpr.value
canvasRef.value.height = scaledHeight * dpr.value
// 3. 设置 Canvas CSS 显示尺寸
canvasRef.value.style.width = `${scaledWidth}px`
canvasRef.value.style.height = `${scaledHeight}px`
// 4. 缩放绘制上下文
const ctx = canvasRef.value.getContext('2d')
ctx.scale(dpr.value, dpr.value)
// 5. 重新绘制
draw()
}
// 主绘制函数
const draw = () => {
const canvas = canvasRef.value;
if (!canvas || !image.value) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制背景图片
ctx.drawImage(image.value, 0, 0, canvas.width / dpr.value, canvas.height / dpr.value);
// 绘制所有标注矩形
rects.value.forEach((rect, index) => {
const { x, y, w, h, text } = rect;
// 绘制矩形主体
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.strokeStyle = RECT_CONFIG.strokeColor;
ctx.lineWidth = RECT_CONFIG.strokeWidth;
ctx.fillStyle = RECT_CONFIG.fillColor;
ctx.fill();
ctx.stroke();
// 绘制矩形文字
if (text) {
ctx.font = `${mergedTextConfig.value.fontSize}px ${mergedTextConfig.value.fontFamily}`;
ctx.fillStyle = mergedTextConfig.value.color;
ctx.textBaseline = 'bottom';
// 计算文字居中位置
const textWidth = ctx.measureText(text).width;
const textX = x + (w - textWidth) / 2;
const textY = y - 8; // 调整垂直偏移更美观
ctx.fillText(text, textX, textY);
}
// 绘制激活状态的缩放控制点
if (index === activeRectIndex.value) {
ctx.fillStyle = RECT_CONFIG.handleColor;
ctx.fillRect(
x + w - RECT_CONFIG.handleSize / 2, // 右下角x坐标
y + h - RECT_CONFIG.handleSize / 2, // 右下角y坐标
RECT_CONFIG.handleSize, // 控制点宽度
RECT_CONFIG.handleSize // 控制点高度
);
}
});
// 绘制临时新矩形(绘制模式时显示虚线框)
if (isDrawingNewRect.value) {
const { x: startX, y: startY } = newRectStart.value;
const { x: currentX, y: currentY } = newRectCurrent.value;
const rectX = Math.min(startX, currentX);
const rectY = Math.min(startY, currentY);
const rectW = Math.abs(currentX - startX);
const rectH = Math.abs(currentY - startY);
ctx.beginPath();
ctx.rect(rectX, rectY, rectW, rectH);
ctx.strokeStyle = RECT_CONFIG.strokeColor;
ctx.lineWidth = RECT_CONFIG.strokeWidth;
ctx.setLineDash([5, 3]); // 虚线样式
ctx.stroke();
ctx.setLineDash([]); // 重置为实线
}
};
// 清除所有矩形
const clearAllRects = () => {
rects.value = [];
activeRectIndex.value = -1;
draw();
};
// 鼠标按下事件(核心交互入口)
const handleMouseDown = (e) => {
const canvas = canvasRef.value;
if (!canvas || !image.value) return;
const point = getCanvasPoint(e.clientX, e.clientY);
const x = point.x;
const y = point.y;
// 检查是否点击现有矩形内部
const clickedRectIndex = rects.value.findIndex(rect =>
x >= rect.x && x <= rect.x + rect.w &&
y >= rect.y && y <= rect.y + rect.h
);
if (clickedRectIndex !== -1) {
// 进入移动模式
activeRectIndex.value = clickedRectIndex;
operationType.value = 'move';
startPos.value = { x, y };
initialRect.value = { ...rects.value[clickedRectIndex] };
isDrawingNewRect.value = false;
canvasCursor.value = 'move';
} else {
// 检查是否点击缩放控制点(当前选中矩形的右下角)
const currentRect = rects.value[activeRectIndex.value];
if (activeRectIndex.value !== -1 && currentRect) {
const handleX = currentRect.x + currentRect.w - RECT_CONFIG.handleSize / 2;
const handleY = currentRect.y + currentRect.h - RECT_CONFIG.handleSize / 2;
if (
x >= handleX && x <= handleX + RECT_CONFIG.handleSize &&
y >= handleY && y <= handleY + RECT_CONFIG.handleSize
) {
// 进入缩放模式
operationType.value = 'resize';
startPos.value = { x, y };
initialRect.value = { ...currentRect };
isDrawingNewRect.value = false;
canvasCursor.value = 'nwse-resize';
draw();
return;
}
}
// 未命中任何矩形/控制点:开始绘制新矩形
isDrawingNewRect.value = true;
newRectStart.value = { x, y };
newRectCurrent.value = { x, y };
activeRectIndex.value = -1;
operationType.value = null;
canvasCursor.value = 'crosshair';
draw();
}
};
// 鼠标移动事件(处理绘制/移动/缩放)
const handleMouseMove = (e) => {
const canvas = canvasRef.value;
if (!canvas || !image.value) return;
const point = getCanvasPoint(e.clientX, e.clientY);
const x = point.x;
const y = point.y;
if (isDrawingNewRect.value) {
// 更新临时矩形坐标(绘制模式)
newRectCurrent.value = { x, y };
canvasCursor.value = 'crosshair';
draw();
return;
}
if (activeRectIndex.value === -1 || rects.value.length === 0) {
// 检查是否悬停在现有矩形上
const isOverRect = rects.value.some(rect =>
x >= rect.x && x <= rect.x + rect.w &&
y >= rect.y && y <= rect.y + rect.h
);
// 检查是否悬停在缩放控制点上
const currentRect = rects.value[activeRectIndex.value];
const isOverHandle = currentRect && (
x >= currentRect.x + currentRect.w - RECT_CONFIG.handleSize / 2 &&
x <= currentRect.x + currentRect.w + RECT_CONFIG.handleSize / 2 &&
y >= currentRect.y + currentRect.h - RECT_CONFIG.handleSize / 2 &&
y <= currentRect.y + currentRect.h + RECT_CONFIG.handleSize / 2
);
canvasCursor.value = isOverHandle ? 'nwse-resize' : (isOverRect ? 'move' : 'default');
return;
}
const deltaX = x - startPos.value.x;
const deltaY = y - startPos.value.y;
// 更新当前操作的矩形状态
rects.value = rects.value.map((rect, index) => {
if (index === activeRectIndex.value) {
if (operationType.value === 'move') {
// 移动逻辑(带偏移量,避免跳跃)
const offsetX = startPos.value.x - initialRect.value.x;
const offsetY = startPos.value.y - initialRect.value.y;
return {
...rect,
x: x - offsetX,
y: y - offsetY
};
} else if (operationType.value === 'resize') {
// 缩放逻辑(保持左上角固定,最小尺寸20x20)
return {
...rect,
w: Math.max(20, initialRect.value.w + deltaX),
h: Math.max(20, initialRect.value.h + deltaY)
};
}
}
return rect;
});
draw();
};
// 鼠标抬起事件(结束操作)
const handleMouseUp = () => {
if (isDrawingNewRect.value) {
// 完成新矩形绘制(保存到列表)
const rectX = Math.min(newRectStart.value.x, newRectCurrent.value.x);
const rectY = Math.min(newRectStart.value.y, newRectCurrent.value.y);
const rectW = Math.abs(newRectCurrent.value.x - newRectStart.value.x);
const rectH = Math.abs(newRectCurrent.value.y - newRectStart.value.y);
if (rectW > 5 && rectH > 5) { // 过滤过小的无效矩形
rects.value.push({
x: rectX,
y: rectY,
w: rectW,
h: rectH,
text: mergedTextConfig.value.content || ''
});
}
isDrawingNewRect.value = false;
canvasCursor.value = 'default';
draw();
} else {
// 结束移动/缩放操作
activeRectIndex.value = -1;
operationType.value = null;
canvasCursor.value = 'default';
}
};
// 键盘删除功能
const handleDeleteRect = () => {
if (activeRectIndex.value !== -1) {
rects.value = rects.value.filter((_, index) => index !== activeRectIndex.value);
activeRectIndex.value = -1;
draw();
}
};
const handleKeyDown = (e) => {
if (e.key === 'Delete') {
handleDeleteRect();
}
};
// 保存图片功能
const uploadImgUrl = ref(store.getters.requestUrl + '/common/upload');
const headers = ref({ Authorization: 'Bearer ' + getToken() });
const saveImage = async () => {
if (!canvasRef.value || !image.value) {
alert('请先加载图片');
return;
}
const canvas = canvasRef.value;
// 确保画布内容已更新
draw();
try {
// 将 canvas 转换为 Blob(二进制格式)
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(resolve, 'image/png', 1);
});
if (!blob) {
throw new Error('无法生成图片Blob');
}
const form = new FormData()
form.append('file', blob)
// 发送请求
const response = await fetch(uploadImgUrl.value, {
method: 'POST',
body: form,
headers: {
...headers.value,
}
})
console.log(response);
return await response.json()
} catch (error) {
console.error('保存图片失败:', error);
}
};
defineExpose({
saveImage
});
// 监听依赖变化自动重绘
watch([image, rects, mergedTextConfig], () => {
draw();
}, { deep: true });
watch(
() => props.textConfig,
(newVal) => {
mergedTextConfig.value = { ...newVal };
},
{ deep: true }
);
// 监听窗口大小变化,重新调整画布
watch(() => window.innerWidth, () => {
resizeCanvas(0.8);
});
</script>
<style scoped>
.annotator-container {
position: relative;
top: 0;
left: 0;
display: inline-block;
width: 100%;
height: 400px;
text-align: center;
}
.control-buttons {
margin-bottom: 10px;
position: absolute;
top: 70%;
right: 200px;
}
button {
padding: 6px 12px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
margin-right: 8px;
}
button:hover {
background: #1976D2;
}
.canvas {
border: 1px solid #ddd;
max-width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.operation-tip {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
}
</style>
3.需要主要的是我这边的保存是在父组件里面 调用子组件里面的方法实现的
后端需要的是二进制的图片,看自己的需求修改