效果:
页面描述:
对给定的几张图片,每张能用鼠标在图上画框,标注相关文字,框的颜色和文字内容能自定义改变,能删除任意画过的框。
实现思路:
1、对给定的这几张图片,用分页器绑定展示,能选择图片;
2、图片上绑定事件@mousedown鼠标按下——开始画矩形、@mousemove鼠标移动——绘制中临时画矩形、@mouseup鼠标抬起——结束画矩形重新渲染;
开始画矩形:鼠标按下,记录鼠标按下的位置。遍历标签数组,找到check值为true的标签,用其样式和名字创建新的标签,加入该图片的矩形框们的数组。注意,监听鼠标如果是按下后马上抬起,结束标注。
更新矩形:识别到新的标签存在,鼠标移动时监听移动距离,更新当前矩形宽高,用canvas绘制实时临时矩形。
结束画矩形:刷新该图片的矩形框们的数组,触发重新渲染。
3、在图片上v-for遍历渲染矩形框,盒子绑定动态样式改变宽高;
4、右侧能添加、修改矩形框颜色和文字;
5、列举出每个矩形框名称,能选择进行删除,还能一次清空;
<template>
<div class="allbody">
<div class="body-top">
<button class="top-item2" @click="clearAnnotations">清空</button>
</div>
<div class="body-btn">
<div class="btn-content">
<div class="image-container">
<!-- <img :src="imageUrl" alt="Character Image" /> -->
<img :src="state.imageUrls[state.currentPage - 1]" @mousedown="startAnnotation" @mousemove="updateAnnotation" @mouseup="endAnnotation" />
<!-- 使用canvas覆盖在图片上方,用于绘制临时矩形 -->
<canvas ref="annotationCanvas"></canvas>
<div v-for="annotation in annotations[state.currentPage - 1]" :key="annotation.id" class="annotation" :style="annotationStyle(annotation)">
<div class="label">{{ annotation.label }}</div>
</div>
</div>
<Pagination
v-model:current="state.currentPage"
v-model:page-size="state.pageSize"
show-quick-jumper
:total="state.imageUrls.length"
:showSizeChanger="false"
:show-total="total => `共 ${total} 张`" />
</div>
<div class="sidebar">
<div class="sidebar-title">标签</div>
<div class="tags">
<div class="tags-item" v-for="(tags, index2) in state.tagsList" :key="index2" @click="checkTag(index2)">
<div class="tags-checkbox">
<div :class="tags.check === true ? 'checkbox-two' : 'notcheckbox-two'"></div>
</div>
<div class="tags-right">
<input class="tags-color" type="color" v-model="tags.color" />
<input type="type" class="tags-input" v-model="tags.name" />
<button class="tags-not" @click="deleteTag(index2)"><DeleteOutlined style="color: #ff0202" /></button>
</div>
</div>
</div>
<div class="sidebar-btn">
<button class="btn-left" @click="addTags()">添加</button>
</div>
<div class="sidebar-title">数据</div>
<div class="sidebars">
<div class="sidebar-item" v-for="(annotation, index) in annotations[state.currentPage - 1]" :key="annotation.id">
<div class="sidebar-item-font">{{ index + 1 }}.{{ annotation.name }}</div>
<button class="sidebar-item-icon" @click="removeAnnotation(annotation.id)"><DeleteOutlined style="color: #ff0202" /></button> </div
></div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { DeleteOutlined } from '@ant-design/icons-vue';
import { Pagination } from 'ant-design-vue';
interface State {
tagsList: any;
canvasX: number;
canvasY: number;
currentPage: number;
pageSize: number;
imageUrls: string[];
};
const state = reactive<State>({
tagsList: [], // 标签列表
canvasX: 0,
canvasY: 0,
currentPage: 1,
pageSize: 1,
imageUrls: [apiUrl.value + '/api/File/Image/annexpic/20241203Q9NHJ.jpg', apiUrl.value + '/api/file/Image/document/20241225QBYXZ.jpg'],
});
interface Annotation {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
color: string;
label: string;
border: string;
};
const annotations = reactive<Array<Annotation[]>>([[]]);
let currentAnnotation: Annotation | null = null;
//开始标注
function startAnnotation(event: MouseEvent) {
// 获取当前选中的标签
var tagsCon = { id: 1, check: true, color: '#000000', name: '安全帽' };
// 遍历标签列表,获取当前选中的标签
for (var i = 0; i < state.tagsList.length; i++) {
if (state.tagsList[i].check) {
tagsCon.id = state.tagsList[i].id;
tagsCon.check = state.tagsList[i].check;
tagsCon.color = state.tagsList[i].color;
tagsCon.name = state.tagsList[i].name;
}
}
// 创建新的标注
currentAnnotation = {
id: crypto.randomUUID(),
name: tagsCon.name,
x: event.offsetX,
y: event.offsetY,
width: 0,
height: 0,
color: '#000000',
label: (annotations[state.currentPage - 1].length || 0) + 1 + tagsCon.name,
border: tagsCon.color,
};
annotations[state.currentPage - 1].push(currentAnnotation);
//记录鼠标按下的位置
state.canvasX = event.offsetX;
state.canvasY = event.offsetY;
//监听鼠标如果是按下后马上抬起,结束标注
const mouseupHandler = () => {
endAnnotation();
window.removeEventListener('mouseup', mouseupHandler);
};
window.addEventListener('mouseup', mouseupHandler);
}
//更新标注
function updateAnnotation(event: MouseEvent) {
if (currentAnnotation) {
//更新当前标注的宽高,为负数时,鼠标向左或向上移动
currentAnnotation.width = event.offsetX - currentAnnotation.x;
currentAnnotation.height = event.offsetY - currentAnnotation.y;
}
//如果正在绘制中,更新临时矩形的位置
if (annotationCanvas.value) {
const canvas = annotationCanvas.value;
//取得类名为image-container的div的宽高
const imageContainer = document.querySelector('.image-container');
canvas.width = imageContainer?.clientWidth || 800;
canvas.height = imageContainer?.clientHeight || 534;
const context = canvas.getContext('2d');
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = currentAnnotation?.border || '#000000';
context.lineWidth = 2;
context.strokeRect(state.canvasX, state.canvasY, currentAnnotation?.width || 0, currentAnnotation?.height || 0);
}
}
}
function endAnnotation() {
//刷新annotations[state.currentPage - 1],触发重新渲染
annotations[state.currentPage - 1] = annotations[state.currentPage - 1].slice();
currentAnnotation = null;
}
function annotationStyle(annotation: Annotation) {
//如果宽高为负数,需要调整left和top的位置
const left = annotation.width < 0 ? annotation.x + annotation.width : annotation.x;
const top = annotation.height < 0 ? annotation.y + annotation.height : annotation.y;
return {
left: `${left}px`,
top: `${top}px`,
width: `${Math.abs(annotation.width)}px`,
height: `${Math.abs(annotation.height)}px`,
border: `2px solid ${annotation.border}`,
};
}
// 选择标签
function checkTag(index2: number) {
state.tagsList.forEach((item, index) => {
if (index === index2) {
item.check = true;
} else {
item.check = false;
}
});
}
// 删除标签
function deleteTag(index: number) {
state.tagsList.splice(index, 1);
}
function addTags() {
state.tagsList.push({ id: state.tagsList.length + 1, check: false, color: '#000000', name: '' });
}
// 移除某个标注
function removeAnnotation(id: string) {
const index = annotations[state.currentPage - 1].findIndex(a => a.id === id);
if (index !== -1) {
annotations[state.currentPage - 1].splice(index, 1);
}
}
// 清空所有标注
function clearAnnotations() {
annotations[state.currentPage - 1].splice(0, annotations[state.currentPage - 1].length);
}
onMounted(() => {
for (let i = 0; i < state.imageUrls.length; i++) {
annotations.push([]);
}
});
</script>
<style>
.body-top {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 10px;
width: 85%;
}
.top-item1 {
width: 70px;
height: 28px;
line-height: 26px;
text-align: center;
background-color: #028dff;
border: 1px solid #028dff;
border-radius: 5px;
font-size: 14px;
color: #fff;
margin-left: 20px;
}
.top-item2 {
width: 70px;
height: 28px;
line-height: 26px;
text-align: center;
background-color: rgb(255, 2, 2);
border: 1px solid rgb(255, 2, 2);
border-radius: 5px;
font-size: 14px;
color: #fff;
margin-left: 20px;
}
.body-btn {
margin: 0;
padding: 10px 13px 0 0;
min-height: 630px;
display: flex;
background-color: #f5f5f5;
}
.btn-content {
flex-grow: 1;
padding: 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.image-container {
height: 500px;
margin: 40px;
}
.image-container img {
height: 500px !important;
}
.ant-pagination {
margin-bottom: 18px;
}
.number-input {
width: 70px;
border: 1px solid #ccc;
border-radius: 4px;
text-align: center;
font-size: 16px;
background-color: #f9f9f9;
outline: none;
color: #66afe9;
}
.sidebar {
display: flex;
flex-direction: column;
width: 280px;
height: 640px;
background-color: #fff;
padding: 10px;
border-radius: 7px;
}
.sidebar-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.sidebars {
overflow: auto;
}
.sidebar .tags {
margin-bottom: 10px;
}
.tags-item {
display: flex;
flex-direction: row;
align-items: center;
}
.tags-checkbox {
width: 24px;
height: 24px;
border-radius: 50px;
border: 1px solid #028dff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 7px;
}
.checkbox-two {
background-color: #028dff;
width: 14px;
height: 14px;
border-radius: 50px;
}
.notcheckbox-two {
width: 14px;
height: 14px;
border-radius: 50px;
border: 1px solid #028dff;
}
.tags-right {
display: flex;
flex-direction: row;
align-items: center;
background-color: #f5f5f5;
border-radius: 5px;
padding: 5px;
width: 90%;
}
.tags-color {
width: 26px;
height: 26px;
border-radius: 5px;
}
.tags-input {
border: 1px solid #fff;
width: 153px;
margin: 0 10px;
}
.tags-not {
border: 1px solid #f5f5f5;
font-size: 12px;
}
.sidebar-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: right;
}
.btn-left {
width: 60px;
height: 28px;
line-height: 26px;
text-align: center;
border: 1px solid #028dff;
border-radius: 5px;
font-size: 14px;
color: #028dff;
}
.btn-right {
width: 60px;
height: 28px;
line-height: 26px;
text-align: center;
background-color: #028dff;
border: 1px solid #028dff;
border-radius: 5px;
font-size: 14px;
color: #fff;
margin-left: 10px;
}
.sidebar-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 2px;
}
.sidebar-item-font {
margin-right: 10px;
}
.sidebar-item-icon {
font-size: 12px;
border: 1px solid #fff;
}
.image-annotator {
display: flex;
height: 100%;
}
.image-container {
flex: 1;
position: relative;
overflow: auto;
}
.image-container img {
max-width: 100%;
height: auto;
}
.annotation {
position: absolute;
box-sizing: border-box;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 防止遮挡鼠标事件 */
}
</style>