前端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.需要主要的是我这边的保存是在父组件里面 调用子组件里面的方法实现的

后端需要的是二进制的图片,看自己的需求修改

相关推荐
百思可瑞教育2 小时前
Vue中使用keep-alive实现页面前进刷新、后退缓存的完整方案
前端·javascript·vue.js·缓存·uni-app·北京百思可瑞教育
yinuo2 小时前
Uni-App跨端实战:APP的WebView与H5通信全流程解析(03)
前端
yinuo2 小时前
Uni-App跨端实战:支付宝小程序WebView与H5通信全流程解析(02)
前端
GISer_Jing3 小时前
sqb&ks二面(准备)
前端·javascript·面试
谢尔登3 小时前
【Webpack】模块联邦
前端·webpack·node.js
前端码虫3 小时前
2.9Vue创建项目(组件)的补充
javascript·vue.js·学习
Bottle4144 小时前
深入探究 React Fiber(译文)
前端
汤姆Tom4 小时前
JavaScript Proxy 对象详解与应用
前端·javascript
BillKu4 小时前
Vue3中app.mount(“#app“)应用挂载原理解析
javascript·vue.js·css3