使用leafer-ui,实现一个图片标注类

在现代前端应用中,图片标注功能被广泛应用于标注系统、图像处理、教育培训等场景。一个交互友好、性能良好的图片标注工具不仅能提高效率,还能提升用户体验。本文将介绍如何使用 Leafer-UI ------ 一款基于 WebCanvas 的高性能图形引擎,来实现一个简洁而实用的图片标注类。

一、Leafer-UI 简介

Leafer-UI 是一个支持图形拖拽、缩放、旋转、文本编辑的可视化 UI 图形框架,底层使用 leafer.ts 提供图形渲染能力,支持像素级精度操作,同时也具备较好的性能优化和响应式特性,适用于图形编辑器、可视化标注、在线设计等应用场景。

二、实现思路

我们要实现的"图片标注类"包含以下核心功能:

  1. 加载并显示一张图片;
  2. 支持用户在图片上添加矩形框或文本注释;
  3. 支持标注框拖动、缩放、删除等交互;
  4. 导出标注数据(如位置、内容等)以用于后续处理。

三、基础环境搭建

我们以 Vue 3 + Vite 为基础来开发项目,首先安装依赖:

js 复制代码
npm install leafer-ui

初始化 Leafer:

html 复制代码
<template>
  <div id="leafer-view" ref="leaferViewRef"></div>
</template>

<script setup lang="ts">
import '@leafer-in/editor';
import '@leafer-in/viewport';
import '@leafer-in/view';
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
import { DrawManger } from '@/utils/useDraw';
import { emitter } from '@/utils/mitt';
import { useTagStore } from '@/store';
import { ANNOTATION_STYLE } from '@/utils/parms';

const mode = defineModel('mode', { type: String });

const tagStore = useTagStore();

// 响应式变量
const leaferViewRef = ref<HTMLDivElement>();

let drawManger: DrawManger | null = null;

/**
 * 初始化应用
 */
const initApp = async (): Promise<void> => {
  if (!leaferViewRef.value) return;

  drawManger = new DrawManger(leaferViewRef.value);

  try {
    await drawManger.init();
  } catch (error) {
    console.error('初始化失败', error);
  }
};

/** 更新画布图片 */
const handleImgUrlChange = async (url: string) => {
  if (!drawManger) return;
  await drawManger.setImage(url);
};

const handleCommand = async (type: string = 'select'): Promise<void> => {
  if (!type && !drawManger) return;

  try {
    const modeActions: Record<string, () => void> = {
      select: () => drawManger?.select(),
      edit: () => drawManger?.edit(),
      move: () => drawManger?.move(),
      zoomIn: () => drawManger?.zoomIn(),
      zoomOut: () => drawManger?.zoomOut(),
      reset: () => drawManger?.centerImageElement(),
      delete: () => drawManger?.clearRectangles()
    };

    const action = modeActions[type];
    if (action) {
      action();
    }
  } catch (e) {
    console.error('执行命令出错:', e);
  } finally {
    await nextTick();
    mode.value = '';
  }
};

watch(
  () => mode.value,
  async newVal => {
    console.log(newVal);
    await handleCommand(newVal);
  },
  { immediate: true }
);

watch(
  () => tagStore.labelActive,
  newVal => {
    console.log(newVal, '88888888');

    if (newVal !== null) {
      console.log(ANNOTATION_STYLE[newVal], '555555555');

      drawManger?.setRectStyle(ANNOTATION_STYLE[newVal]);
    }
  }
);

// 生命周期钩子
onMounted(async () => {
  try {
    await initApp();
    emitter.on('imgUrl', handleImgUrlChange);
  } catch (error) {
    console.error('应用初始化失败', error);
  }
});

onUnmounted(() => {
  if (drawManger) {
    drawManger.destroy();
  }
  emitter.off('imgUrl', handleImgUrlChange);
});
</script>

<style scoped>
#leafer-view {
  width: 100%;
  height: calc(100% - 30px);
}
</style>

四、创建标注类

我们可以封装一个标注类 DrawManger,用于添加标注元素和管理状态:

ts 复制代码
import { App, Image, ImageEvent, PointerEvent, Rect, Text } from 'leafer-ui';
import { DotMatrix } from 'leafer-x-dot-matrix';
import * as CONFIG from '@/utils/parms';
import { useTagStore } from '@/store';
import { ElMessage } from 'element-plus';
import { genUUID } from '@/utils/uuid';

const tagStore = useTagStore();

// 操作模式枚举
export enum DrawMode {
  SELECT = 'select',
  EDIT = 'edit',
  MOVE = 'move'
}

// 矩形和标签的组合接口
interface RectWithLabel {
  rect: Rect;
  label: Text;
  id: string; // 唯一标识符
}

export class DrawManger {
  public app: App | null = null;
  private dotMatrix: DotMatrix | null = null;
  private imageElement: Image | null = null;

  // 操作模式管理
  private currentMode: DrawMode = DrawMode.SELECT;

  // 绘制状态管理
  private isDrawing: boolean = false;
  private startPoint: { x: number; y: number } | null = null;
  private currentRect: Rect | null = null;
  private currentLabel: Text | null = null;
  private rectangleWithLabels: RectWithLabel[] = []; // 存储所有绘制的矩形和标签

  // 移动状态管理
  private isMovingImage: boolean = false;
  private isMovingRect: boolean = false;
  private selectedRectWithLabel: RectWithLabel | null = null;
  private lastImagePosition: { x: number; y: number } | null = null;
  private dragStartPoint: { x: number; y: number } | null = null;

  // 绘画矩形UI
  private fillColor: string = 'rgb(93,152,214,0.4)';
  private strokeColor: string = 'rgb(93,152,214)';
  private strokeWidth: number = 4;

  // 标签样式配置
  private labelStyle = {
    fontSize: 12,
    fill: '#ffffff',
    backgroundColor: 'rgba(0,0,0,0.5)',
    padding: [2, 10]
  };

  constructor(private container: HTMLDivElement) {}

  public async init() {
    this.app = new App({
      view: this.container,
      editor: {}
    });

    this.dotMatrix = new DotMatrix(this.app, {
      dotSize: CONFIG.DOT_SIZE,
      targetDotMatrixPixel: CONFIG.TARGET_DOT_MATRIX_PIXEL
    });

    this.dotMatrix.enableDotMatrix(true);
    this.bindEventListeners();
  }

  /**
   * 异步设置图像
   */
  public async setImage(url: string): Promise<void> {
    if (!this.app) throw new Error('app未初始化');

    this.clearRectangles();
    // 清除之前的图片
    if (this.imageElement) {
      this.app.tree.remove(this.imageElement);
      this.imageElement = null;
    }
    // 创建一个新的图像实例并设置其属性
    return new Promise((resolve, reject) => {
      const image = new Image({
        url,
        draggable: false
      });
      // 监听图像加载成功的事件
      image.once(ImageEvent.LOADED, (e: ImageEvent) => {
        try {
          // 成功加载后,调用中心化图像的函数,并更新当前图像元素,然后解析Promise
          this.centerImage(image, e);
          this.imageElement = image;
          resolve();
        } catch (error) {
          // 如果中心化图像过程中出现错误,拒绝Promise
          reject(error);
        }
      });
      // 监听图像加载失败的事件
      image.once(ImageEvent.ERROR, () => {
        // 加载失败时,拒绝Promise并抛出错误
        reject(new Error('图片加载失败'));
      });
      // 如果app未初始化,直接返回
      if (!this.app) return;
      // 将新图像添加到应用的树结构中
      this.app.tree.add(image);
    });
  }

  private centerImage(image: Image, e: ImageEvent) {
    if (!this.app) return;
    const canvasWidth = this.app.tree.canvas.width / 2;
    const canvasHeight = this.app.tree.canvas.height / 2;
    const { width, height } = e.image;

    image.set({
      x: canvasWidth - width / 2,
      y: canvasHeight - height / 2
    });
  }

  /** 图片居中 */
  public centerImageElement() {
    if (!this.app || !this.imageElement) return;

    const canvasWidth = this.app.tree.canvas.width / 2;
    const canvasHeight = this.app.tree.canvas.height / 2;

    const { width, height } = this.imageElement;

    if (typeof width !== 'number' || typeof height !== 'number') {
      console.warn('图片尺寸无效');
      return;
    }

    this.app.tree.zoom(1);

    this.imageElement.set({
      x: canvasWidth - width / 2,
      y: canvasHeight - height / 2
    });
  }

  /** 设置操作模式 */
  public setMode(mode: DrawMode) {
    this.currentMode = mode;
    this.resetStates();
    this.updateRectanglesInteractivity();
    this.updateImageInteractivity();
    console.log(`切换到${mode}模式`);
  }

  /** 导出 select 方法 */
  public select() {
    this.setMode(DrawMode.SELECT);
  }

  /** 导出 edit 方法 */
  public edit() {
    this.setMode(DrawMode.EDIT);
  }

  /** 导出 move 方法 */
  public move() {
    this.setMode(DrawMode.MOVE);
  }

  /** 放大 */
  public zoomIn() {
    if (!this.app) return;
    this.app.tree.zoom('in');
  }

  /** 缩小 */
  public zoomOut() {
    if (!this.app) return;
    this.app.tree.zoom('out');
  }

  /** 清除所有矩形和标签 */
  public clearRectangles() {
    if (!this.app) return;

    this.rectangleWithLabels.forEach(({ rect, label }) => {
      this.app!.tree.remove(rect);
      this.app!.tree.remove(label);
    });
    this.rectangleWithLabels = [];

    tagStore.setActive(null);
    tagStore.setName('');
    tagStore.setHave(false);

    // 清除当前正在绘制的矩形和标签
    if (this.currentRect) {
      this.app.tree.remove(this.currentRect);
      this.currentRect = null;
    }
    if (this.currentLabel) {
      this.app.tree.remove(this.currentLabel);
      this.currentLabel = null;
    }
  }

  /** 获取所有矩形的坐标信息和标签(相对于图片的坐标) */
  public getRectangles(): Array<{
    id: string;
    label: string;
    x: number;
    y: number;
    width: number;
    height: number;
  }> {
    // 如果没有图片,返回空数组
    if (!this.imageElement) {
      return [];
    }

    const imageX = this.imageElement.x as number;
    const imageY = this.imageElement.y as number;

    return this.rectangleWithLabels.map(({ rect, label, id }) => {
      const rectX = rect.x as number;
      const rectY = rect.y as number;
      const rectWidth = rect.width as number;
      const rectHeight = rect.height as number;

      return {
        id,
        label: label.text as string,
        // 转换为相对于图片的坐标
        x: rectX - imageX,
        y: rectY - imageY,
        width: rectWidth,
        height: rectHeight
      };
    });
  }

  /** 从相对于图片的坐标数据恢复标注框 */
  public setRectangles(
    rectangles: Array<{
      id?: string;
      label: string;
      x: number;
      y: number;
      width: number;
      height: number;
    }>
  ): void {
    if (!this.app || !this.imageElement) {
      console.warn('应用或图片未初始化');
      return;
    }

    // 清除现有的标注框
    this.clearRectangles();

    const imageX = this.imageElement.x as number;
    const imageY = this.imageElement.y as number;

    rectangles.forEach(rectData => {
      // 将相对坐标转换为绝对坐标
      const absoluteX = rectData.x + imageX;
      const absoluteY = rectData.y + imageY;

      // 创建矩形
      const rect = this.createRectangle(absoluteX, absoluteY, absoluteX + rectData.width, absoluteY + rectData.height);

      // 创建标签
      const label = this.createLabel(rectData.label, absoluteX, absoluteY);
      this.updateLabelPosition(label, rect);

      // 添加到画布
      this.app!.tree.add(rect);
      this.app!.tree.add(label);

      // 创建组合对象并添加到数组
      const rectWithLabel: RectWithLabel = {
        rect,
        label,
        id: rectData.id || genUUID()
      };

      this.rectangleWithLabels.push(rectWithLabel);
      this.updateSingleRectInteractivity(rectWithLabel);
    });

    console.log(`恢复了 ${rectangles.length} 个标注框`);
  }

  /**
   * 释放资源并清理应用程序,销毁应用实例
   */
  public destroy() {
    if (this.app) {
      this.unbindEventListeners();
      this.app.destroy();
      this.app = null;
    }

    this.imageElement = null;
    this.dotMatrix = null;
    this.rectangleWithLabels = [];
    this.currentRect = null;
    this.currentLabel = null;
    this.startPoint = null;
    this.selectedRectWithLabel = null;
    this.dragStartPoint = null;
    this.lastImagePosition = null;
    this.isDrawing = false;
    this.isMovingImage = false;
    this.isMovingRect = false;
  }

  /** 重置所有状态 */
  private resetStates() {
    this.isDrawing = false;
    this.isMovingImage = false;
    this.isMovingRect = false;
    this.startPoint = null;
    this.selectedRectWithLabel = null;
    this.dragStartPoint = null;
    this.lastImagePosition = null;
    this.fillColor = 'rgb(93,152,214,0.2)';
    this.strokeColor = 'rgb(93,152,214)';
    this.strokeWidth = 5;

    // 清除当前绘制的矩形和标签
    if (this.currentRect && this.app) {
      this.app.tree.remove(this.currentRect);
      this.currentRect = null;
    }
    if (this.currentLabel && this.app) {
      this.app.tree.remove(this.currentLabel);
      this.currentLabel = null;
    }
  }

  /** 更新矩形的交互性 */
  private updateRectanglesInteractivity() {
    this.rectangleWithLabels.forEach(({ rect, label }) => {
      switch (this.currentMode) {
        case DrawMode.SELECT:
          rect.set({ draggable: false, editable: false, cursor: 'default' });
          label.set({ draggable: false, cursor: 'default' });
          break;
        case DrawMode.EDIT:
          rect.set({ draggable: true, editable: true, cursor: 'move' });
          label.set({ draggable: false, cursor: 'move' });
          break;
        case DrawMode.MOVE:
          rect.set({ draggable: true, editable: false, cursor: 'move' });
          label.set({ draggable: false, cursor: 'move' });
          break;
      }
    });
  }

  /** 更新图片的交互性 */
  private updateImageInteractivity() {
    if (!this.imageElement) return;

    switch (this.currentMode) {
      case DrawMode.SELECT:
        this.imageElement.set({ draggable: false, editable: false });
        break;
      case DrawMode.EDIT:
        this.imageElement.set({ draggable: false, editable: false });
        break;
      case DrawMode.MOVE:
        this.imageElement.set({ draggable: true, editable: false });
        break;
    }
  }

  /** 创建标签 */
  private createLabel(text: string, x: number, y: number): Text {
    return new Text({
      text,
      x,
      y,
      fontSize: this.labelStyle.fontSize,
      fill: this.labelStyle.fill,
      padding: this.labelStyle.padding,
      draggable: false,
      boxStyle: {
        fill: this.labelStyle.backgroundColor,
        stroke: 'black'
      }
    });
  }

  /** 更新标签位置 */
  private updateLabelPosition(label: Text, rect: Rect) {
    const rectX = rect.x as number;
    const rectY = rect.y as number;

    label.set({
      x: rectX,
      y: rectY - this.labelStyle.fontSize - this.labelStyle.padding[0] * 2 - 6 // 在矩形上方
    });
  }

  /** 绘画矩形 */
  private createRectangle(startX: number, startY: number, endX: number, endY: number): Rect {
    const x = Math.min(startX, endX);
    const y = Math.min(startY, endY);
    const width = Math.abs(endX - startX);
    const height = Math.abs(endY - startY);

    return new Rect({
      x,
      y,
      width,
      height,
      fill: this.fillColor,
      stroke: this.strokeColor,
      strokeWidth: this.strokeWidth,
      draggable: false
    });
  }

  /** 更新当前正在绘制的矩形和标签 */
  private updateCurrentRect(endX: number, endY: number) {
    if (!this.currentRect || !this.startPoint) return;

    const x = Math.min(this.startPoint.x, endX);
    const y = Math.min(this.startPoint.y, endY);
    const width = Math.abs(endX - this.startPoint.x);
    const height = Math.abs(endY - this.startPoint.y);

    this.currentRect.set({
      x,
      y,
      width,
      height
    });

    // 同时更新标签位置
    if (this.currentLabel) {
      this.updateLabelPosition(this.currentLabel, this.currentRect);
    }
  }

  /** 绑定事件监听器 */
  private bindEventListeners() {
    if (!this.app) return;
    this.app.on(PointerEvent.DOWN, this.onPointerDown);
    this.app.on(PointerEvent.MOVE, this.onPointerMove);
    this.app.on(PointerEvent.UP, this.onPointerUp);
    this.app.on(PointerEvent.CLICK, this.onPointerClick);
  }

  /** 解绑事件监听器 */
  private unbindEventListeners() {
    if (!this.app) return;

    this.app.off(PointerEvent.DOWN, this.onPointerDown);
    this.app.off(PointerEvent.MOVE, this.onPointerMove);
    this.app.off(PointerEvent.UP, this.onPointerUp);
    this.app.off(PointerEvent.CLICK, this.onPointerClick);
  }

  /** 检查点是否在图片内部 */
  private isPointInImage(x: number, y: number): boolean {
    if (!this.imageElement) return false;

    const imgX = this.imageElement.x as number;
    const imgY = this.imageElement.y as number;
    const imgWidth = this.imageElement.width as number;
    const imgHeight = this.imageElement.height as number;

    return x >= imgX && x <= imgX + imgWidth && y >= imgY && y <= imgY + imgHeight;
  }

  /** 将坐标限制在图片边界内 */
  private clampToImageBounds(x: number, y: number): { x: number; y: number } {
    if (!this.imageElement) return { x, y };

    const imgX = this.imageElement.x as number;
    const imgY = this.imageElement.y as number;
    const imgWidth = this.imageElement.width as number;
    const imgHeight = this.imageElement.height as number;

    return {
      x: Math.max(imgX, Math.min(x, imgX + imgWidth)),
      y: Math.max(imgY, Math.min(y, imgY + imgHeight))
    };
  }
  /** 检查是否可以开始绘制 - 验证label是否为空 */
  private canStartDrawing(): boolean {
    const labelHave = tagStore.labelHave;
    const labelName = tagStore.labelName;
    return !!(labelName && labelName.length > 0 && labelHave);
  }

  /** 显示label验证错误信息 */
  private showLabelValidationError() {
    ElMessage({
      type: 'error',
      message: '请先设置标注名称才能开始绘制',
      plain: true
    });
  }

  /** SELECT 模式下的鼠标按下处理 */
  private handleSelectModeDown(e: PointerEvent) {
    const point = e.getPagePoint();
    // 只能在图片内部开始绘制矩形
    if (!this.isPointInImage(point.x, point.y)) return;

    // 验证label是否为空
    if (!this.canStartDrawing()) {
      this.showLabelValidationError();
      return; // 阻止绘制
    }

    this.startPoint = { x: point.x, y: point.y };
    this.isDrawing = true;
    this.currentRect = this.createRectangle(point.x, point.y, point.x, point.y);

    // 创建对应的标签
    const label = `${tagStore.labelName}`;
    this.currentLabel = this.createLabel(
      label,
      point.x,
      point.y - this.labelStyle.fontSize - this.labelStyle.padding[0] * 2 - 6
    );

    this.app!.tree.add(this.currentRect);
    this.app!.tree.add(this.currentLabel);
  }

  /** EDIT 模式下的鼠标按下处理 */
  private handleEditModeDown(e: PointerEvent) {
    const point = e.getPagePoint();
    // 查找点击的矩形
    const rectWithLabel = this.findRectWithLabelAtPoint(point.x, point.y);

    if (rectWithLabel && this.app) {
      // 更改选中编辑颜色为和标注同一种颜色
      this.app.editor.config.stroke = rectWithLabel.rect.stroke;
      this.app.editor.config.fill = rectWithLabel.rect.fill;
      this.app.editor.updateEditTool();
      this.selectedRectWithLabel = rectWithLabel;
      this.isMovingRect = true;
    }
  }

  /** MOVE 模式下的鼠标按下处理 */
  private handleMoveModeDown(e: PointerEvent) {
    const point = e.getPagePoint();
    // 优先检查是否点击了矩形
    const rectWithLabel = this.findRectWithLabelAtPoint(point.x, point.y);
    if (rectWithLabel) {
      this.selectedRectWithLabel = rectWithLabel;
      this.isMovingRect = true;
    } else if (this.isPointInImage(point.x, point.y)) {
      // 点击图片区域,准备移动图片
      this.isMovingImage = true;
      this.recordImagePosition();
    }
  }

  /** SELECT 模式下的鼠标移动处理 */
  private handleSelectModeMove(e: PointerEvent) {
    const point = e.getPagePoint();

    if (!this.isDrawing || !this.startPoint || !this.currentRect) return;

    const clampedPoint = this.clampToImageBounds(point.x, point.y);
    this.updateCurrentRect(clampedPoint.x, clampedPoint.y);
  }

  /** EDIT 模式下的鼠标移动处理 */
  private handleEditModeMove(e: PointerEvent) {
    const point = e.getPagePoint();

    if (!this.isMovingRect || !this.selectedRectWithLabel || !this.dragStartPoint) return;

    const deltaX = point.x - this.dragStartPoint.x;
    const deltaY = point.y - this.dragStartPoint.y;

    const rect = this.selectedRectWithLabel.rect;
    const label = this.selectedRectWithLabel.label;

    const newX = (rect.x as number) + deltaX;
    const newY = (rect.y as number) + deltaY;

    // 限制矩形在图片范围内移动
    const clampedPosition = this.clampRectToImageBounds(newX, newY, rect.width as number, rect.height as number);

    rect.set(clampedPosition);
    this.updateLabelPosition(label, rect);
    this.dragStartPoint = { x: point.x, y: point.y };
  }

  /** MOVE 模式下的鼠标移动处理 */
  private handleMoveModeMove(e: PointerEvent) {
    const point = e.getPagePoint();
    if (this.isMovingRect && this.selectedRectWithLabel && this.dragStartPoint) {
      // 移动矩形和标签(与EDIT模式相同)
      const deltaX = point.x - this.dragStartPoint.x;
      const deltaY = point.y - this.dragStartPoint.y;

      const rect = this.selectedRectWithLabel.rect;
      const label = this.selectedRectWithLabel.label;

      const newX = (rect.x as number) + deltaX;
      const newY = (rect.y as number) + deltaY;

      const clampedPosition = this.clampRectToImageBounds(newX, newY, rect.width as number, rect.height as number);

      rect.set(clampedPosition);
      this.updateLabelPosition(label, rect);
      this.dragStartPoint = { x: point.x, y: point.y };
    } else if (this.isMovingImage && this.imageElement && this.dragStartPoint) {
      // 移动图片
      const deltaX = point.x - this.dragStartPoint.x;
      const deltaY = point.y - this.dragStartPoint.y;

      const newX = (this.imageElement.x as number) + deltaX;
      const newY = (this.imageElement.y as number) + deltaY;

      this.imageElement.set({ x: newX, y: newY });
      this.syncRectanglesWithImage();
      this.dragStartPoint = { x: point.x, y: point.y };
    }
  }

  /** 将矩形位置限制在图片边界内 */
  private clampRectToImageBounds(x: number, y: number, width: number, height: number): { x: number; y: number } {
    if (!this.imageElement) return { x, y };

    const imgX = this.imageElement.x as number;
    const imgY = this.imageElement.y as number;
    const imgWidth = this.imageElement.width as number;
    const imgHeight = this.imageElement.height as number;

    return {
      x: Math.max(imgX, Math.min(x, imgX + imgWidth - width)),
      y: Math.max(imgY, Math.min(y, imgY + imgHeight - height))
    };
  }

  /** SELECT 模式下的鼠标抬起处理 */
  private handleSelectModeUp(e: PointerEvent) {
    const point = e.getPagePoint();
    if (!this.isDrawing || !this.startPoint || !this.currentRect || !this.currentLabel || !this.app) return;

    const clampedPoint = this.clampToImageBounds(point.x, point.y);
    const width = Math.abs(clampedPoint.x - this.startPoint.x);
    const height = Math.abs(clampedPoint.y - this.startPoint.y);

    if (width > 5 && height > 5) {
      this.updateCurrentRect(clampedPoint.x, clampedPoint.y);

      // 创建矩形和标签的组合对象
      const rectWithLabel: RectWithLabel = {
        rect: this.currentRect,
        label: this.currentLabel,
        id: genUUID()
      };

      this.rectangleWithLabels.push(rectWithLabel);
      this.updateSingleRectInteractivity(rectWithLabel);

      console.log('矩形和标签绘制完成:', {
        id: rectWithLabel.id,
        label: this.currentLabel.text,
        x: this.currentRect.x,
        y: this.currentRect.y,
        width: this.currentRect.width,
        height: this.currentRect.height
      });
    } else {
      this.app.tree.remove(this.currentRect);
      this.app.tree.remove(this.currentLabel);
    }

    this.isDrawing = false;
    this.startPoint = null;
    this.currentRect = null;
    this.currentLabel = null;
  }

  /** EDIT 模式下的鼠标抬起处理 */
  private handleEditModeUp() {
    this.isMovingRect = false;
    this.selectedRectWithLabel = null;
  }

  /** MOVE 模式下的鼠标抬起处理 */
  private handleMoveModeUp() {
    this.isMovingImage = false;
    this.isMovingRect = false;
    this.selectedRectWithLabel = null;
    this.lastImagePosition = null;
  }

  /** 查找指定坐标下的矩形和标签组合 */
  private findRectWithLabelAtPoint(x: number, y: number): RectWithLabel | null {
    for (let i = this.rectangleWithLabels.length - 1; i >= 0; i--) {
      const rectWithLabel = this.rectangleWithLabels[i];
      const rect = rectWithLabel.rect;
      const rectX = rect.x as number;
      const rectY = rect.y as number;
      const rectWidth = rect.width as number;
      const rectHeight = rect.height as number;

      if (x >= rectX && x <= rectX + rectWidth && y >= rectY && y <= rectY + rectHeight) {
        return rectWithLabel;
      }
    }
    return null;
  }

  /** 记录图片初始位置 */
  private recordImagePosition() {
    if (this.imageElement) {
      this.lastImagePosition = {
        x: this.imageElement.x as number,
        y: this.imageElement.y as number
      };
    }
  }

  /** 图片移动时同步移动矩形和标签 */
  private syncRectanglesWithImage() {
    if (!this.imageElement || !this.lastImagePosition) return;

    const currentImageX = this.imageElement.x as number;
    const currentImageY = this.imageElement.y as number;
    const deltaX = currentImageX - this.lastImagePosition.x;
    const deltaY = currentImageY - this.lastImagePosition.y;

    // 同步移动所有矩形和标签
    this.rectangleWithLabels.forEach(({ rect, label }) => {
      const currentRectX = rect.x as number;
      const currentRectY = rect.y as number;
      const currentLabelX = label.x as number;
      const currentLabelY = label.y as number;

      rect.set({
        x: currentRectX + deltaX,
        y: currentRectY + deltaY
      });

      label.set({
        x: currentLabelX + deltaX,
        y: currentLabelY + deltaY
      });
    });

    // 更新记录的图片位置
    this.lastImagePosition = { x: currentImageX, y: currentImageY };
  }

  /** 更新单个矩形和标签的交互性 */
  private updateSingleRectInteractivity(rectWithLabel: RectWithLabel) {
    const { rect, label } = rectWithLabel;
    switch (this.currentMode) {
      case DrawMode.SELECT:
        rect.set({ draggable: false, cursor: 'default' });
        label.set({ draggable: false, cursor: 'default' });
        break;
      case DrawMode.EDIT:
      case DrawMode.MOVE:
        rect.set({ draggable: true, cursor: 'move' });
        label.set({ draggable: false, cursor: 'move' });
        break;
    }
  }

  /** 鼠标按下 - 根据模式执行不同操作 */
  onPointerDown = (e: PointerEvent): void => {
    const point = e.getPagePoint();
    if (!this.app || !this.imageElement) return;

    this.dragStartPoint = { x: point.x, y: point.y };

    switch (this.currentMode) {
      case DrawMode.SELECT:
        this.handleSelectModeDown(e);
        break;
      case DrawMode.EDIT:
        this.handleEditModeDown(e);
        break;
      case DrawMode.MOVE:
        this.handleMoveModeDown(e);
        break;
    }
  };

  /** 鼠标移动 - 根据模式执行不同操作 */
  onPointerMove = (e: PointerEvent): void => {
    if (!this.dragStartPoint) return;

    switch (this.currentMode) {
      case DrawMode.SELECT:
        this.handleSelectModeMove(e);
        break;
      case DrawMode.EDIT:
        this.handleEditModeMove(e);
        break;
      case DrawMode.MOVE:
        this.handleMoveModeMove(e);
        break;
    }
  };

  /** 鼠标抬起 - 根据模式执行不同操作 */
  onPointerUp = (e: PointerEvent): void => {
    switch (this.currentMode) {
      case DrawMode.SELECT:
        this.handleSelectModeUp(e);
        break;
      case DrawMode.EDIT:
        this.handleEditModeUp();
        break;
      case DrawMode.MOVE:
        this.handleMoveModeUp();
        break;
    }

    // 重置拖拽状态
    this.dragStartPoint = null;
  };

  /** 鼠标点击 */
  onPointerClick = (): void => {
    // 点击事件可以用于其他功能,比如选择已有的矩形
  };

  /** 设置几何体样式 */
  public setRectStyle(style: any) {
    this.fillColor = style.fill;
    this.strokeColor = style.stroke;
    this.strokeWidth = style.strokeWidth;
  }

  /** 设置标签样式 */
  public setLabelStyle(style: Partial<typeof this.labelStyle>) {
    this.labelStyle = { ...this.labelStyle, ...style };
  }

  /** 更新指定矩形的标签文本 */
  public updateLabelText(rectId: string, newText: string): boolean {
    const rectWithLabel = this.rectangleWithLabels.find(item => item.id === rectId);
    if (rectWithLabel) {
      rectWithLabel.label.set({ text: newText });
      return true;
    }
    return false;
  }
}

效果

相关推荐
风吹头皮凉6 分钟前
vue实现气泡词云图
前端·javascript·vue.js
南玖i7 分钟前
vue3 + ant 实现 tree默认展开,筛选对应数据打开,简单~直接cv
开发语言·前端·javascript
小钻风336628 分钟前
深入浅出掌握 Axios(持续更新)
前端·javascript·axios
萌萌哒草头将军36 分钟前
🚀🚀🚀尤雨溪推荐的这个库你一定要知道!轻量⚡️,优雅!
前端·vue.js·react.js
三门1 小时前
docker安装mysql8.0.20过程
前端
BillKu1 小时前
Vue3 + Vite 中使用 Lodash-es 的防抖 debounce 详解
前端·javascript·vue.js
一只小风华~2 小时前
HTML前端开发:JavaScript的条分支语句if,Switch
前端·javascript·html5
橙子家2 小时前
Select 组件实现【全选】(基于 Element)
前端
超级土豆粉2 小时前
HTML 语义化
前端·html
bingbingyihao2 小时前
UI框架-通知组件
前端·javascript·vue