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

效果:

页面描述:

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

实现思路:

**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>
相关推荐
一念永恒@19 分钟前
vue2新增删除
前端·javascript·vue.js
岸边的风37 分钟前
vue中mixin的理解,有那些使用场景?
前端·javascript·vue.js
时间sk1 小时前
CSS——6. 导入样式
前端·css
玩具工匠1 小时前
字玩FontPlayer开发笔记9 Tauri2打包应用
前端·笔记
椒盐大肥猫1 小时前
vue3运行时执行过程步骤
javascript·vue.js·ecmascript
骆驼Lara1 小时前
Vue3.5 企业级管理系统实战(一):项目初始搭建与配置
前端·vue.js
黑云压城After1 小时前
uniapp web-view调整修改高度设置
前端·javascript·uni-app
问老大1 小时前
uniapp实现在card卡片组件内为图片添加长按保存、识别二维码等功能
前端·javascript·uni-app
她说她一如既往的爱我1 小时前
如何写一个uniapp自定义tarbar导航栏?
前端·vue.js·windows·uni-app
大大艺术家2 小时前
安装vue脚手架出现的一系列问题
前端·javascript·vue.js