前端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.valueclickedRectIndex };

isDrawingNewRect.value = false;

canvasCursor.value = 'move';

} else {

// 检查是否点击缩放控制点(当前选中矩形的右下角)

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

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

相关推荐
卸任15 小时前
Vibe coding工具使用心得
前端·ai编程
brien916 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance16 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M16 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写16 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李16 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易17 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马17 小时前
前沿视界
前端
kyriewen17 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易17 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员