Vue3 + TypeScript 实现图片查看弹窗组件(支持标注、缩放、拖拽)

Vue3 + TypeScript 实现图片查看弹窗组件(支持标注、缩放、拖拽)

前言

在工业质检领域的智能化转型浪潮中,面临一个典型的可视化需求场景:通过计算机视觉算法检测出产品表面瑕疵后,需要在前端界面高精度还原检测结果,实现瑕疵位置的可视化标注与交互式查验。

思路

需求需要展示高分辨率图片,并且标注出算法检测到的瑕疵位置,我才用了vue3+canvas来实现当前的需求功能,使用ElementUI弹窗组件,弹窗组件内部有个divdiv包含Canvas容器,用来显示图片和瑕疵标注,canvas本身没有滚动属性,我设置canvas的宽高大于外面盒子的宽高,使其包含canvasdiv出现滚动,再通过监听divhandleMouseDownhandleMouseMovehandleMouseUpmousedownmousemovemouseupmouseleave】来假象控制canvas的移动。缩放与放大通过监听wheel滚轮事件触发,计算缩放系数,计算鼠标相对于canvas左上角的位置,根据缩放系数更新缩放的比例,根据鼠标位置调整图片位置即可。

效果

位置标注效果

放大标注不偏移效果

拖拽移动不偏移效果

功能概述

  1. 本组件实现了一个 支持缩放拖拽的图片标注查看器,主要功能包括:。

  2. 全屏弹窗模式:基于 Element UI 的 Dialog 组件实现。

  3. 超高分辨率支持:Canvas 画布尺寸 10000x10000。

  4. 动态缩放:鼠标滚轮缩放(带中心点跟随)。

  5. 自由拖拽:按住鼠标拖动查看图片不同区域。

  6. 瑕疵标注显示:绘制矩形框+文字标注。

  7. 状态重置:关闭弹窗时自动重置缩放和位置。

  8. 性能优化:禁用图像平滑处理,保持高清显示。

技术栈

  1. Vue3:组合式 API + TypeScript
  2. Element Plus:弹窗组件
  3. Canvas:核心绘图逻辑
  4. Pinia:状态管理(瑕疵数据)

核心实现解析

画布初始化

组件通过 props 接收图片路径 imageSrc,并将其加载到 <canvas> 元素中,使用 reactive 管理图片状态(如缩放比例、坐标等)。图片加载完成后调用 drawImage 方法绘制图片。

typescript 复制代码
const loadImageToCanvas = () => {
  const canvas = myCanvas.value!;
  canvas.width = 10000; // 固定画布尺寸
  canvas.height = 10000;
  
  // 高质量渲染配置
  const ctx = canvas.getContext("2d")!;
  ctx.imageSmoothingEnabled = false; 
  ctx.imageSmoothingQuality = "high";
  
  // 图片加载逻辑
  imgState.img.src = props.imageSrc;
  imgState.img.onload = () => {
    // 初始位置计算
    imgState.x = (canvas.width - img.width * scale) / 2;
    imgState.y = (canvas.height - img.height * scale) / 2;
    drawImage(ctx);
  };
};

动态缩放实现

使用 wheel 事件监听滚轮操作,根据鼠标位置动态调整图片的缩放中心。

typescript 复制代码
const handleWheel = (event: WheelEvent) => {
  const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9; // 缩放系数
  
  // 计算鼠标相对位置
  const rect = canvas.getBoundingClientRect();
  const mouseX = event.clientX - rect.left;
  const mouseY = event.clientY - rect.top;
  
  // 应用缩放
  imgState.scale *= zoomFactor;
  
  // 调整图片位置保持中心点
  imgState.x += (mouseX - imgState.x) * (1 - zoomFactor);
  imgState.y += (mouseY - imgState.y) * (1 - zoomFactor);
  
  // 边界约束
  imgState.x = Math.max(0, Math.min(imgState.x, canvas.width - scaledWidth));
  drawImage(ctx); // 重绘
};

拖拽滚动逻辑

使用 mousedown、mousemove 和 mouseup 事件实现拖拽功能。动态更新画布的滚动位置。

typescript 复制代码
// 鼠标按下记录初始状态
const handleMouseDown = (e: MouseEvent) => {
  isDragging = true;
  startX = e.pageX;
  startY = e.pageY;
};

const handleMouseMove = (e: MouseEvent) => {
  if (!isDragging) return;
  container.scrollLeft = startScrollLeft - (e.pageX - startX);
  container.scrollTop = startScrollTop - (e.pageY - startY);
};

const handleMouseUp = () => {
  isDragging = false;
};

标注绘制流程

组件支持接收标注数据 defectCoordinates,并在图片上绘制矩形框和文字标注,根据 classID 查找对应的瑕疵信息(如名称、颜色)。使用 strokeRect 绘制矩形框,fillText 添加文字标注。

typescript 复制代码
const drawImage = (ctx: CanvasRenderingContext2D) => {
  // 绘制基础图片
  ctx.drawImage(img, 0, 0, img.width * scale, img.height * scale);
  // 循环绘制所有标注
  props.defectCoordinates.forEach(item => {
    // 解析坐标
    const [x1, y1, x2, y2] = item.bbox;
    
    // 绘制矩形框
    ctx.strokeStyle = `rgb(${color})`;
    ctx.strokeRect(x1*scale, y1*scale, width*scale, height*scale);
    
    // 绘制居中文字
    ctx.fillText(label, centerX, y1*scale - 5);
  });
};

事件监听与清理

为了防止内存泄漏,组件在弹窗关闭时会移除所有事件监听器,并清空画布状态。

typescript 复制代码
watch(() => props.visible, async (newVal) => {
  dialogVisible.value = newVal;
  if (newVal) {
    await nextTick();
    loadImageToCanvas();
    myCanvas.value.addEventListener("wheel", handleWheel);
  } else {
    myCanvas.value.removeEventListener("wheel", handleWheel);
    clearCanvas();
  }
});

关键问题解决方案

1. 高清渲染优化
typescript 复制代码
// 禁用抗锯齿
ctx.imageSmoothingEnabled = false;
// 设置高质量缩放
ctx.imageSmoothingQuality = "high";
2. 状态管理
typescript 复制代码
// 使用 reactive 管理图片状态
let imgState = reactive({
  img: new Image(),
  scale: 0.245,
  x: 0,
  y: 0
});

// 清空时保持响应式
const clearCanvas = () => {
  Object.assign(imgState, {
    img: new Image(),
    scale: 0.245,
    x: 0,
    y: 0
  });
};

总体代码【复制即可使用】:

typescript 复制代码
<template>
  <div class="content-box" style="position: relative">
    <el-dialog
      @close="handleClose"
      v-model="dialogVisible"
      fullscreen
      top="40vh"
      draggable
    >
      <SvgIcon
        name="ele-Close"
        class="close-icon"
        @click="handleClose"
      ></SvgIcon>
      <div
        ref="canvasBox"
        class="canvas-container"
        @mousedown="handleMouseDown"
        @mousemove="handleMouseMove"
        @mouseup="handleMouseUp"
      >
        <canvas ref="myCanvas" style="width: 10000px; height: 10000px"></canvas>
      </div>
    </el-dialog>
  </div>
</template>

<script lang="ts" setup >
import { nextTick, onMounted, watch, reactive } from "vue";
import { ref } from "vue";
import { useAuthStore } from "../../stores/auth";
const authStore = useAuthStore();
// console.log("authStore", authStore.defectList);
// 定义 defectCoordinates 的类型
interface DefectCoordinate {
  bbox: [number, number, number, number]; // 确保 bbox 是一个包含四个数字的数组
  classID: number; // 标注类别 ID
  confidence: number; // 置信度
}
// 定义 Props
const props = defineProps({
  visible: {
    type: Boolean,
    default: false,
  },
  imageSrc: {
    type: String,
    required: true,
  },
  defectCoordinates: {
    type: Array as () => DefectCoordinate[],
    default: () => [],
  },
});
// 定义 Emits
const emit = defineEmits(["update:visible"]);
const canvasBox = ref();
const dialogVisible: any = ref(false);
const myCanvas = ref<HTMLCanvasElement | null>(null);
// 图片状态
let imgState: any = reactive({
  img: new Image(),
  scale: 0.245, // 缩放比例
  x: 0, // 图片左上角的 x 坐标
  y: 0, // 图片左上角的 y 坐标
  rectangles: [] as {
    x1: number; // 左上角 x
    y1: number; // 左上角 y
    x2: number; // 右下角 x
    y2: number; // 右下角 y
    label: string; // 标注文字
  }[], // 存储矩形框数据
});

let isDragging = false;
let startX = 0;
let startY = 0;
let startScrollLeft = 0;
let startScrollTop = 0;
// 加载图片到 Canvas
const loadImageToCanvas = () => {
  const canvas: any = myCanvas.value;
  if (!canvas) return;

  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false; // 禁用平滑处理.
  ctx.imageSmoothingQuality = "high"; // 使用高质量的缩放算法

  // 设置 Canvas 尺寸 2736 x 3648
  canvas.width = 10000;
  canvas.height = 10000;
  // 加载图片
  imgState.img.src = props.imageSrc;

  imgState.img.onload = () => {
    // 初始化图片位置和缩放比例
    // imgState.scale = Math.min(
    //   canvas.width / imgState.img.width,
    //   canvas.height / imgState.img.height
    // );
    imgState.x = (canvas.width - imgState.img.width * imgState.scale) / 5;
    imgState.y = (canvas.height - imgState.img.height * imgState.scale) / 5;

    imgState.x = imgState.img.width;
    imgState.y = imgState.img.height;

    drawImage(ctx);
  };

  imgState.img.onerror = (err: any) => {
    console.error("图片加载失败:", err);
  };
};

// 绘制图片
const drawImage = (ctx: CanvasRenderingContext2D) => {
  const { img, scale, x, y, rectangles } = imgState;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 清空画布
  ctx.drawImage(img, 0, 0, img.width * scale, img.height * scale);
  for (let i = 0; i < props.defectCoordinates.length; i++) {
    // 根据ID获取瑕疵信息
    const tempXcInfo = findBlemishNameBySort(
      String(props.defectCoordinates[i].classID)
    );
    // 获取当前元素
    const item = props.defectCoordinates[i];

    // 从bbox数组中提取坐标
    const [x1, y1, x2, y2] = item.bbox;

    // 计算矩形的信息
    const rectX = Math.min(x1, x2);
    const rectY = Math.min(y1, y2);
    const rectWidth = Math.abs(x2 - x1);
    const rectHeight = Math.abs(y2 - y1);
    // 设置绘图样式
    ctx.strokeStyle = `rgb(${tempXcInfo.blemishRGBColor})`;
    ctx.lineWidth = 1;
    // 绘制矩形
    ctx.strokeRect(
      rectX * scale,
      rectY * scale,
      rectWidth * scale,
      rectHeight * scale
    );

    // 计算文字位置并考虑缩放比例
    const textX = (Math.min(x1, x2) + Math.abs(x2 - x1) / 2) * imgState.scale;
    const textY = Math.min(y1, y2) * imgState.scale;
    // 根据缩放比例调整文字大小
    const fontSize = 16; // 假设原始字体大小为 14px
    ctx.font = `${fontSize}px Arial`;

    if (tempXcInfo) {
      // 绘制标注文字
      ctx.fillStyle = `rgb(${tempXcInfo.blemishRGBColor})`; // 文字颜色
      ctx.textAlign = "center"; // 文字水平居中
      ctx.textBaseline = "bottom"; // 文字垂直对齐方式
      ctx.fillText(tempXcInfo.blemishName, textX, textY); // 文字位置
    }
  }
  // 根据Id查找瑕疵信息
  function findBlemishNameBySort(classIdValue: string) {
    const foundItem = authStore.defectList.find(
      (item: any) => item.classId === classIdValue
    );
    return foundItem ? foundItem : null;
  }
};

// 鼠标按下时
const handleMouseDown = (e: MouseEvent) => {
  isDragging = true;
  const container = canvasBox.value as HTMLElement;

  // 记录初始值
  startX = e.pageX;
  startY = e.pageY;
  startScrollLeft = container.scrollLeft;
  startScrollTop = container.scrollTop;

  // 更改鼠标样式
  container.style.cursor = "grabbing";
};

// 鼠标移动时
const handleMouseMove = (e: MouseEvent) => {
  if (!isDragging) return;

  const container = canvasBox.value as HTMLElement;

  // 计算鼠标移动的距离
  const deltaX = e.pageX - startX;
  const deltaY = e.pageY - startY;

  // 更新滚动位置
  container.scrollLeft = startScrollLeft - deltaX;
  container.scrollTop = startScrollTop - deltaY;
};

// 鼠标释放时
const handleMouseUp = () => {
  isDragging = false;
  const container = canvasBox.value as HTMLElement;
  // 恢复鼠标样式
  container.style.cursor = "grab";
};

// 处理滚轮缩放
const handleWheel = (event: WheelEvent) => {
  event.preventDefault();

  const canvas: any = myCanvas.value;
  if (!canvas) return;
  // getBoundingClientRect
  const rect: any = canvas.getBoundingClientRect();
  const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9; // 放大或缩小因子

  // 计算鼠标相对于canvas左上角的位置
  const mouseX = event.clientX - rect.left;
  const mouseY = event.clientY - rect.top;

  // 更新缩放比例
  imgState.scale *= zoomFactor;

  // 根据鼠标位置调整图片位置
  imgState.x += (mouseX - imgState.x) * (1 - zoomFactor);
  imgState.y += (mouseY - imgState.y) * (1 - zoomFactor);

  // 边界限制
  const scaledWidth = imgState.img.width * imgState.scale;
  const scaledHeight = imgState.img.height * imgState.scale;
  imgState.x = Math.min(Math.max(imgState.x, 0), canvas.width - scaledWidth);
  imgState.y = Math.min(Math.max(imgState.y, 0), canvas.height - scaledHeight);
  // console.log("imgState.scale", imgState.scale);
  drawImage(canvas.getContext("2d"));
};

// 清空画布
const clearCanvas = () => {
  const canvas: any = myCanvas.value;
  if (!canvas) return;
  imgState = {
    img: new Image(),
    scale: 0.245, // 缩放比例
    x: 0, // 图片左上角的 x 坐标
    y: 0, // 图片左上角的 y 坐标
    rectangles: [] as {
      x1: number; // 左上角 x
      y1: number; // 左上角 y
      x2: number; // 右下角 x
      y2: number; // 右下角 y
      label: string; // 标注文字
    }[], // 存储矩形框数据
  };
  isDragging = false;
  startX = 0;
  startY = 0;
  startScrollLeft = 0;
  startScrollTop = 0;
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布内容
  const container = canvasBox.value as HTMLElement;
  // 更新滚动位置
  container.scrollLeft = 0;
  container.scrollTop = 0;
};
// 监听父组件传递的 visible 状态变化
// 监听父组件传递的 visible 状态变化
watch(
  () => props.visible,
  async (newVal) => {
    dialogVisible.value = newVal;
    if (props.visible) {
      await nextTick();
      loadImageToCanvas();

      // 添加滚轮事件监听器
      if (myCanvas.value && myCanvas.value instanceof HTMLCanvasElement) {
        myCanvas.value.addEventListener("wheel", handleWheel);
        myCanvas.value.addEventListener("mousedown", handleMouseDown);
        myCanvas.value.addEventListener("mousemove", handleMouseMove);
        myCanvas.value.addEventListener("mouseup", handleMouseUp);
        myCanvas.value.addEventListener("mouseleave", handleMouseUp);
      }
    } else {
      // 弹窗关闭时,移除事件监听器但不销毁画布
      if (myCanvas.value && myCanvas.value instanceof HTMLCanvasElement) {
        myCanvas.value.removeEventListener("wheel", handleWheel);
        myCanvas.value.removeEventListener("mousedown", handleMouseDown);
        myCanvas.value.removeEventListener("mousemove", handleMouseMove);
        myCanvas.value.removeEventListener("mouseup", handleMouseUp);
        myCanvas.value.removeEventListener("mouseleave", handleMouseUp);
      }
    }
  }
);
const handleClose = () => {
  dialogVisible.value = false;
  // 清空画布,初始化画布数据
  clearCanvas();
};
// 当弹窗关闭时,通知父组件更新状态
watch(dialogVisible, (newVal) => {
  if (!newVal) {
    emit("update:visible", false);
  }
});
</script>

<style lang="scss" scoped>
.content-box {
  width: 100%;
  height: 100%;
  background: white;
}
.canvas-container {
  width: 100%; /* 容器宽度 */
  height: calc(100vh); /* 容器高度 */
  overflow: auto; /* 显示滚动条 */
  // border: 1px solid #ccc; /* 边框样式 */
  padding-left: 30%;
  cursor: grab; /* 默认鼠标样式 */
  // background: rgba(19, 19, 19, 0.7);
  overflow-x: hidden;
  overflow-y: hidden;
  margin-top: 10px;
}
canvas {
  display: block; /* 避免默认的 inline-block 导致的布局问题 */
}
::v-deep .el-dialog.is-fullscreen {
  background: rgba(19, 19, 19, 0.7);
}
::v-deep .el-dialog {
  padding: 0 !important;
}
::v-deep .el-dialog__header {
  display: none;
}

.close-icon {
  position: absolute;
  right: 10px;
  top: 5px;
  cursor: pointer;
  color: #fff;
  font-size: 40px !important;
}
.close-icon:hover {
  color: #165dff !important;
}
</style>

如果觉得本教程有帮助,欢迎点赞⭐收藏📝!如有任何问题,欢迎在评论区留言讨论~

相关推荐
云端看世界几秒前
ECMAScript 函数this全解析 下
前端·javascript
云端看世界10 分钟前
ECMAScript 函数对象之调用
前端·javascript·ecmascript 6
编程老菜鸡14 分钟前
Vue3-原始值的响应式方案ref
前端·javascript·vue.js
小陈同学,,21 分钟前
el-table怎么显示 特殊单元格的值
前端·el-table
Henry2you44 分钟前
新手引导-纯js手搓
前端
小桥风满袖44 分钟前
Three.js-硬要自学系列13 (加载gltf外部模型、加载大模型)
前端·css·three.js
前端涂涂1 小时前
express-generratior工具用法
前端·后端
正在努力的前端小白1 小时前
Vue3可编辑字段组件的演进之路:从繁琐到优雅
前端·javascript·vue.js
极客小俊1 小时前
粘性定位Position:sticky属性是不是真的没用?
前端
云端看世界1 小时前
ECMAScript 类型转换 下
前端·javascript