前端 图片上鼠标画矩形框,标注文字,任意删除

效果:

页面描述:

对给定的几张图片,每张能用鼠标在图上画框,标注相关文字,框的颜色和文字内容能自定义改变,能删除任意画过的框。

实现思路:

**1、**对给定的这几张图片,用分页器绑定展示,能选择图片;

**2、**图片上绑定事件@mousedown鼠标按下------开始画矩形、@mousemove鼠标移动------绘制中临时画矩形、@mouseup鼠标抬起------结束画矩形重新渲染;

开始画矩形:鼠标按下,记录鼠标按下的位置。遍历标签数组,找到check值为true的标签,用其样式和名字创建新的标签,加入该图片的矩形框们的数组。注意,监听鼠标如果是按下后马上抬起,结束标注。

更新矩形:识别到新的标签存在,鼠标移动时监听移动距离,更新当前矩形宽高,用canvas绘制实时临时矩形。

结束画矩形:刷新该图片的矩形框们的数组,触发重新渲染。

**3、**在图片上v-for遍历渲染矩形框,盒子绑定动态样式改变宽高;

**4、**右侧能添加、修改矩形框颜色和文字;

**5、**列举出每个矩形框名称,能选择进行删除,还能一次清空;

javascript 复制代码
<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>
相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax