Vue3 + TypeScript 实现图片查看弹窗组件(支持标注、缩放、拖拽)
前言
在工业质检领域的智能化转型浪潮中,面临一个典型的可视化需求场景:通过计算机视觉算法检测出产品表面瑕疵后,需要在前端界面高精度还原检测结果,实现瑕疵位置的可视化标注与交互式查验。
思路
需求需要展示高分辨率图片,并且标注出算法检测到的瑕疵位置,我才用了
vue3+canvas
来实现当前的需求功能,使用ElementUI
弹窗组件,弹窗组件内部有个div
,div
包含Canvas
容器,用来显示图片和瑕疵标注,canvas
本身没有滚动属性,我设置canvas
的宽高大于外面盒子的宽高,使其包含canvas
的div
出现滚动,再通过监听div
的handleMouseDown
,handleMouseMove
,handleMouseUp
【mousedown
,mousemove
,mouseupmouseleave
】来假象控制canvas
的移动。缩放与放大通过监听wheel
滚轮事件触发,计算缩放系数,计算鼠标相对于canvas
左上角的位置,根据缩放系数更新缩放的比例,根据鼠标位置调整图片位置即可。
效果
位置标注效果

放大标注不偏移效果
拖拽移动不偏移效果

功能概述
本组件实现了一个 支持缩放拖拽的图片标注查看器,主要功能包括:。
全屏弹窗模式:基于 Element UI 的 Dialog 组件实现。
超高分辨率支持:Canvas 画布尺寸 10000x10000。
动态缩放:鼠标滚轮缩放(带中心点跟随)。
自由拖拽:按住鼠标拖动查看图片不同区域。
瑕疵标注显示:绘制矩形框+文字标注。
状态重置:关闭弹窗时自动重置缩放和位置。
性能优化:禁用图像平滑处理,保持高清显示。
技术栈
- Vue3:组合式 API + TypeScript
- Element Plus:弹窗组件
- Canvas:核心绘图逻辑
- 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>
如果觉得本教程有帮助,欢迎点赞⭐收藏📝!如有任何问题,欢迎在评论区留言讨论~