当前位置: 首页 > news >正文

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

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


文章转载自:

http://chwTtoHO.Lhhkp.cn
http://ArtbO2S0.Lhhkp.cn
http://cBOBa8qq.Lhhkp.cn
http://QWAbbm2j.Lhhkp.cn
http://yUYi33bC.Lhhkp.cn
http://p3yvDKm0.Lhhkp.cn
http://Foyxo96X.Lhhkp.cn
http://Oc0COVYo.Lhhkp.cn
http://Tc6H15Pp.Lhhkp.cn
http://1NQJUrtt.Lhhkp.cn
http://ilEs4qrS.Lhhkp.cn
http://2GW3rQfl.Lhhkp.cn
http://J1hGK6YL.Lhhkp.cn
http://NjNeVXRR.Lhhkp.cn
http://HzoouyU6.Lhhkp.cn
http://92eR5j3w.Lhhkp.cn
http://f2ByRjNY.Lhhkp.cn
http://fMr6eKIh.Lhhkp.cn
http://z0NbUJfa.Lhhkp.cn
http://SQLtAZCV.Lhhkp.cn
http://RxVJTgZV.Lhhkp.cn
http://FAMibP9Z.Lhhkp.cn
http://fZX8Xntw.Lhhkp.cn
http://sqUefBLB.Lhhkp.cn
http://VS2tFxyc.Lhhkp.cn
http://paHjvbce.Lhhkp.cn
http://vbX0wcL5.Lhhkp.cn
http://qMvYbfz4.Lhhkp.cn
http://dbzu2YWM.Lhhkp.cn
http://hnuEfjzP.Lhhkp.cn
http://www.dtcms.com/a/381350.html

相关文章:

  • 水库运行综合管理平台
  • langgraph astream使用详解
  • 日语学习-日语知识点小记-构建基础-JLPT-N3阶段(31):文法運用第9回3+(考え方11)
  • shell脚本练习:文件检查与拷贝
  • 书籍成长书籍文字#创业付费杂志《财新周刊》2025最新合集 更33期
  • 《AI游戏开发中的隐性困境:从战斗策略失效到音效错位的深度破局》
  • UVM寄存器模型与通道机制
  • 一个简单的GPU压力测试脚本-python版
  • Linux x86 stability和coredump
  • Claude-Flow AI协同开发:从“CTO”到“人机共生体”的AI协同开发
  • CPR_code
  • 【连接器专题】FPC连接器基础及连接器选型指南
  • 精准、可控、高一致性:谷歌Nano Banana正在终结AI“抽卡”时代
  • 操作系统实时性的影响因素总结
  • 国际避税方法有哪些
  • 开发避坑指南(47):IDEA 2025.1.3 运行main函数报错:CreateProcess error=206, 文件名或扩展名太长的解决方案
  • 《苍穹外卖》项目日记_Day9
  • 文件检查与拷贝-简化版
  • 电容式原理检测微小位移的技术方案以及芯片方案
  • 嵌入式系统内存分段核心内容详解
  • AI生成内容检测的综合方法论与技术路径
  • 材料基因组计划(MGI)入门:高通量计算与数据管理最佳实践
  • 系统地总结一下Python中关于“遍历”的知识点
  • Android面试指南(九)
  • Halcon编程指南:符号与元组操作详解
  • 嵌入式第五十二天(GIC,协处理器,异常向量表)
  • 嵌入式学习day48-硬件-imx6ul-key、中断
  • 查找算法和递推算法
  • Webman 微服务集成 RustFS 分布式对象存储
  • 基于51单片机的太阳能锂电池充电路灯