最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

使用Vue與Fabric.js開發(fā)一個圖片標注工具

 更新時間:2026年02月13日 09:34:24   作者:_蝦仁不眨眼_  
在現(xiàn)代的Web應用開發(fā)中,能夠對圖片進行標注是一項非常實用的功能,無論是在項目管理、圖像處理還是教育領域,這種功能都能極大提升用戶體驗,本文將介紹如何利用Vue框架結合Fabric.js庫來構建一個具有矩形框選、箭頭繪制和文本添加功能的圖片標注工具,需要的朋友可以參考下

一、技術棧簡介

  • Vue.js:一個用于構建用戶界面的漸進式框架,它使得我們能夠輕松地構建交互式的Web界面。
  • Fabric.js:一個強大的JavaScript庫,它使操作HTML5 canvas元素變得更加簡單。通過Fabric.js,我們可以輕松地添加、修改或刪除canvas上的對象。

二、實現(xiàn)步驟

1. 初始化Vue項目并安裝依賴

首先,你需要有一個Vue項目環(huán)境。如果你還沒有,可以通過Vue CLI快速搭建。接著,安裝fabric作為我們的繪圖庫:

npm install fabric --save

2. 創(chuàng)建ImageAnnotator組件

在本例中,我們創(chuàng)建了一個名為ImageAnnotator的Vue組件,它包含了工具欄(包括矩形、箭頭、文字工具)和畫布區(qū)域。這個組件負責加載圖片,并允許用戶在其上進行標注。

<template>
  <el-dialog
    title="問題圖片標注"
    class="image-annotator-dialog"
    :custom-class="'custom-dialog'"
    :visible.sync="dialogVisible"
    width="1260px"
    :show-close="true"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <div class="annotator-container">
      <!-- 左側工具欄 -->
      <div class="toolbar">
        <el-tooltip content="矩形" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'rect' }"
            @click="setMode('rect')"
          >
            <svg
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              style="display: block"
            >
              <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
            </svg>
          </div>
        </el-tooltip>
        <el-tooltip content="箭頭" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'arrow' }"
            @click="setMode('arrow')"
          >
            <i class="el-icon-top-right"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="文字" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'text' }"
            @click="setMode('text')"
          >
            <svg
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
            >
              <line x1="6" y1="6" x2="18" y2="6"></line>
              <line x1="12" y1="6" x2="12" y2="20"></line>
            </svg>
          </div>
        </el-tooltip>
        <el-divider></el-divider>
        <el-tooltip content="撤銷" placement="right">
          <div class="tool-item" @click="undo">
            <i class="el-icon-refresh-left"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="清空標注" placement="right">
          <div class="tool-item" @click="clearAnnotations">
            <i class="el-icon-delete"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="保存圖片" placement="right">
          <div class="tool-item" @click="saveImage">
            <i class="el-icon-check"></i>
          </div>
        </el-tooltip>
      </div>

      <!-- 畫布區(qū)域 -->
      <div class="canvas-wrapper" ref="canvasWrapper">
        <!--  v-show 避免 DOM 重建問題,或使用 key 強制刷新 -->
        <canvas
          v-show="dialogVisible"
          ref="fabricCanvas"
          :key="canvasKey"
          id="fabric-canvas"
        ></canvas>
        <!-- 圖片加載失敗提示 -->
        <div v-if="loadError" class="load-error">
          <i class="el-icon-picture-outline"></i>
          <p>圖片加載失敗</p>
          <p class="error-detail">{{ errorDetail }}</p>
          <el-button size="small" @click="retryLoad">重試</el-button>
        </div>
        <!-- 加載中提示 -->
        <div v-if="loading" class="loading">
          <i class="el-icon-loading"></i>
          <p>加載中...</p>
        </div>
      </div>
    </div>
    <div class="margin-t tips">
      標記完成請點擊" <i class="el-icon-check"></i>"保存
    </div>
  </el-dialog>
</template>

3. 配置Canvas與事件綁定

初始化Fabric Canvas時,我們需要設置其尺寸,并確保監(jiān)聽鼠標事件以支持不同的繪圖模式(如繪制矩形、箭頭和文本)。通過綁定mouse:down、mouse:movemouse:up事件,我們可以捕捉用戶的繪圖行為。

    // ---------- 畫布初始化 ----------
    initCanvas() {
      // 確保 canvas DOM 已就緒
      if (!this.$refs.fabricCanvas) {
        console.warn("Canvas DOM 未就緒,等待重試...");
        setTimeout(() => this.initCanvas(), 50);
        return;
      }

      // 確保之前的 canvas 實例已徹底銷毀
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷毀舊 canvas 實例失敗", e);
        }
        this.canvas = null;
      }

      // 獲取容器尺寸
      const wrapper = this.$refs.canvasWrapper;
      if (!wrapper) {
        setTimeout(() => this.initCanvas(), 50);
        return;
      }

      const width = Math.max(600, wrapper.clientWidth - 40);
      const height = Math.max(450, wrapper.clientHeight - 40);

      // 創(chuàng)建新的 fabric 畫布
      try {
        const canvasElement = this.$refs.fabricCanvas;
        this.canvas = new fabric.Canvas(canvasElement, {
          width,
          height,
          backgroundColor: "#1e1e1e",
        });
        console.log("Fabric canvas 創(chuàng)建成功", this.canvas);
      } catch (e) {
        console.error("創(chuàng)建 fabric canvas 失敗", e);
        this.$message.error("畫布初始化失敗,請刷新頁面重試");
        return;
      }

      // 加載當前圖片
      this.loadCurrentImage();

      // 綁定鼠標事件 - 使用 fabric 的事件系統(tǒng)替代原生事件
      this._bindCanvasEvents();
    },

    _bindCanvasEvents() {
      if (!this.canvas) return;

      // 移除之前可能綁定的事件
      this.canvas.off("mouse:down");
      this.canvas.off("mouse:move");
      this.canvas.off("mouse:up");

      // 綁定 fabric 事件
      this.canvas.on("mouse:down", (e) => {
        if (!this.currentMode) return;
        this.startPoint = this.canvas.getPointer(e.e);
        this.isDrawing = true;
      });

      this.canvas.on("mouse:move", (e) => {
        if (!this.isDrawing || !this.currentMode || !this.startPoint) return;
        const pointer = this.canvas.getPointer(e.e);
        this.removePreview();
        const preview = this.createPreview(this.startPoint, pointer);
        if (preview) {
          this.canvas.add(preview);
          this.previewObject = preview;
          this.canvas.renderAll();
        }
      });

      this.canvas.on("mouse:up", (e) => {
        if (!this.isDrawing || !this.currentMode || !this.startPoint) return;
        const pointer = this.canvas.getPointer(e.e);
        const left = Math.min(this.startPoint.x, pointer.x);
        const top = Math.min(this.startPoint.y, pointer.y);
        const width = Math.abs(pointer.x - this.startPoint.x);
        const height = Math.abs(pointer.y - this.startPoint.y);

        this.removePreview();

        let newObject = null;

        if (this.currentMode === "rect") {
          if (width >= 5 && height >= 5) {
            newObject = this.createRect(left, top, width, height);
          }
        } else if (this.currentMode === "arrow") {
          if (Math.hypot(width, height) >= 10) {
            newObject = this.createArrow(
              this.startPoint.x,
              this.startPoint.y,
              pointer.x,
              pointer.y
            );
          }
        } else if (this.currentMode === "text") {
          newObject = this.createText(pointer.x, pointer.y);
        }

        if (newObject) {
          this.canvas.add(newObject);
          this.canvas.setActiveObject(newObject);
          this.canvas.renderAll();

          if (this.currentMode === "text" && newObject.enterEditing) {
            newObject.enterEditing();
            newObject.selectAll();
          }
        }

        this.setMode(null);
        this.isDrawing = false;
        this.startPoint = null;
      });

      // 監(jiān)聽對象添加,記錄歷史
      this.canvas.on("object:added", () => {
        this.historyStack.push("added");
      });
    },

4. 支持多種標注類型

  • 矩形框選:用戶可以通過拖動鼠標來選擇圖片中的特定區(qū)域。
  • 箭頭繪制:支持從一點到另一點繪制箭頭,方便指示或強調內容。
  • 文本添加:用戶可以直接在圖片上添加說明性文字。
    // ---------- 標注對象工廠 ----------
    createRect(left, top, width, height) {
      return new fabric.Rect({
        left,
        top,
        width,
        height,
        fill: "transparent",
        stroke: "#ff4d4f",
        strokeWidth: 3,
        strokeUniform: true,
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
      });
    },

    createArrow(x1, y1, x2, y2) {
      const angle = Math.atan2(y2 - y1, x2 - x1) * (180 / Math.PI);
      const length = Math.hypot(x2 - x1, y2 - y1);
      const line = new fabric.Line([0, 0, length - 15, 0], {
        stroke: "#ff4d4f",
        strokeWidth: 3,
        originX: "center",
        originY: "center",
      });
      const triangle = new fabric.Triangle({
        left: length - 7,
        top: 0,
        width: 12,
        height: 12,
        angle: 90,
        fill: "#ff4d4f",
        stroke: "#ff4d4f",
        strokeWidth: 2,
        originX: "center",
        originY: "center",
      });
      return new fabric.Group([line, triangle], {
        left: x1,
        top: y1,
        angle,
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
      });
    },

    createText(left, top) {
      return new fabric.IText("", {
        // 添加默認提示文字
        left,
        top,
        fontSize: 20,
        fill: "#ff4d4f",
        backgroundColor: "rgba(255,255,255,0.8)",
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
        padding: 4,
      });
    },

5. 圖片加載與錯誤處理

為了增強用戶體驗,我們加入了加載提示和錯誤處理機制。當圖片加載失敗時,會顯示友好的錯誤消息,并提供重試選項。

    loadWithImageElement(url) {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.referrerPolicy = "no-referrer";
      img.src = url;

      img.onload = () => {
        this.loading = false;
        this.onImageLoaded(img);
      };

      img.onerror = (err) => {
        console.error("圖片加載失敗:", err, url);
        this.loading = false;
        this.loadError = true;
        this.errorDetail =
          "服務器未返回 CORS 頭,請聯(lián)系圖片提供商或使用同源圖片";
      };
    },

    onImageLoaded(img) {
      if (!this.canvas) {
        console.error("canvas 實例不存在");
        return;
      }

      try {
        const fabricImg = new fabric.Image(img);
        const scale = Math.min(
          this.canvas.width / fabricImg.width,
          this.canvas.height / fabricImg.height,
          1
        );
        fabricImg.scale(scale);
        fabricImg.set({
          left: (this.canvas.width - fabricImg.width * scale) / 2,
          top: (this.canvas.height - fabricImg.height * scale) / 2,
          hasControls: false,
          selectable: false,
          evented: false,
          lockMovementX: true,
          lockMovementY: true,
        });

        this.canvas.clear();
        this.canvas.add(fabricImg);

        // 置底操作
        if (typeof this.canvas.moveTo === "function") {
          this.canvas.moveTo(fabricImg, 0);
        } else if (typeof this.canvas.sendToBack === "function") {
          this.canvas.sendToBack(fabricImg);
        }

        this.canvas.renderAll();
        this.historyStack = [];
      } catch (e) {
        console.error("圖片渲染失敗", e);
        this.loadError = true;
        this.errorDetail = "圖片渲染失敗: " + e.message;
      }
    },

6. 清除標注與撤銷操作

提供了清除所有標注和撤銷最后一次操作的功能,讓用戶可以更靈活地編輯他們的標注。

 undo() {
      if (!this.canvas) return;
      const objects = this.canvas.getObjects();
      if (objects.length > 1) {
        this.canvas.remove(objects[objects.length - 1]);
        this.historyStack.pop();
        this.canvas.renderAll();
      }
    },

三、總結

通過上述步驟,我們構建了一個基本但功能齊全的圖片標注工具。當然,這只是一個起點。你可以根據自己的需求進一步擴展此工具,比如增加更多種類的標注工具、優(yōu)化UI設計或是集成到更大的項目中去。

四、完整組件

<template>
  <el-dialog
    title="問題圖片標注"
    class="image-annotator-dialog"
    :custom-class="'custom-dialog'"
    :visible.sync="dialogVisible"
    width="1260px"
    :show-close="true"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <div class="annotator-container">
      <!-- 左側工具欄 -->
      <div class="toolbar">
        <el-tooltip content="矩形" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'rect' }"
            @click="setMode('rect')"
          >
            <svg
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              style="display: block"
            >
              <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
            </svg>
          </div>
        </el-tooltip>
        <el-tooltip content="箭頭" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'arrow' }"
            @click="setMode('arrow')"
          >
            <i class="el-icon-top-right"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="文字" placement="right">
          <div
            class="tool-item"
            :class="{ active: currentMode === 'text' }"
            @click="setMode('text')"
          >
            <svg
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
            >
              <line x1="6" y1="6" x2="18" y2="6"></line>
              <line x1="12" y1="6" x2="12" y2="20"></line>
            </svg>
          </div>
        </el-tooltip>
        <el-divider></el-divider>
        <el-tooltip content="撤銷" placement="right">
          <div class="tool-item" @click="undo">
            <i class="el-icon-refresh-left"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="清空標注" placement="right">
          <div class="tool-item" @click="clearAnnotations">
            <i class="el-icon-delete"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="保存圖片" placement="right">
          <div class="tool-item" @click="saveImage">
            <i class="el-icon-check"></i>
          </div>
        </el-tooltip>
      </div>

      <!-- 畫布區(qū)域 -->
      <div class="canvas-wrapper" ref="canvasWrapper">
        <!--  v-show 避免 DOM 重建問題,或使用 key 強制刷新 -->
        <canvas
          v-show="dialogVisible"
          ref="fabricCanvas"
          :key="canvasKey"
          id="fabric-canvas"
        ></canvas>
        <!-- 圖片加載失敗提示 -->
        <div v-if="loadError" class="load-error">
          <i class="el-icon-picture-outline"></i>
          <p>圖片加載失敗</p>
          <p class="error-detail">{{ errorDetail }}</p>
          <el-button size="small" @click="retryLoad">重試</el-button>
        </div>
        <!-- 加載中提示 -->
        <div v-if="loading" class="loading">
          <i class="el-icon-loading"></i>
          <p>加載中...</p>
        </div>
      </div>
    </div>
    <div class="margin-t tips">
      標記完成請點擊" <i class="el-icon-check"></i>"保存
    </div>
  </el-dialog>
</template>

<script>
import * as fabric from "fabric";

export default {
  name: "ImageAnnotator",
  props: {
    visible: { type: Boolean, default: false },
    imgSrc: { type: String },
    initialIndex: { type: Number, default: 0 },
  },
  data() {
    return {
      dialogVisible: this.visible,
      currentPage: this.initialIndex + 1,
      canvas: null,
      currentMode: null,
      isDrawing: false,
      startPoint: null,
      historyStack: [],
      previewObject: null,
      loading: false,
      loadError: false,
      errorDetail: "",
      //  添加 canvasKey 用于強制刷新 canvas DOM
      canvasKey: 0,
    };
  },
  watch: {
    visible(val) {
      this.dialogVisible = val;
    },
    dialogVisible(val) {
      this.$emit("update:visible", val);
      if (val) {
        // 每次打開時更新 key,確保 canvas DOM 是全新的
        this.canvasKey += 1;
        this.$nextTick(() => {
          // 確保 DOM 更新后再初始化
          setTimeout(() => {
            this.initCanvas();
          }, 50);
        });
      }
    },
  },
  beforeDestroy() {
    this.handleClosed();
  },
  methods: {
    // ---------- 畫布初始化 ----------
    initCanvas() {
      // 確保 canvas DOM 已就緒
      if (!this.$refs.fabricCanvas) {
        console.warn("Canvas DOM 未就緒,等待重試...");
        setTimeout(() => this.initCanvas(), 50);
        return;
      }

      // 確保之前的 canvas 實例已徹底銷毀
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷毀舊 canvas 實例失敗", e);
        }
        this.canvas = null;
      }

      // 獲取容器尺寸
      const wrapper = this.$refs.canvasWrapper;
      if (!wrapper) {
        setTimeout(() => this.initCanvas(), 50);
        return;
      }

      const width = Math.max(600, wrapper.clientWidth - 40);
      const height = Math.max(450, wrapper.clientHeight - 40);

      // 創(chuàng)建新的 fabric 畫布
      try {
        const canvasElement = this.$refs.fabricCanvas;
        this.canvas = new fabric.Canvas(canvasElement, {
          width,
          height,
          backgroundColor: "#1e1e1e",
        });
        console.log("Fabric canvas 創(chuàng)建成功", this.canvas);
      } catch (e) {
        console.error("創(chuàng)建 fabric canvas 失敗", e);
        this.$message.error("畫布初始化失敗,請刷新頁面重試");
        return;
      }

      // 加載當前圖片
      this.loadCurrentImage();

      // 綁定鼠標事件 - 使用 fabric 的事件系統(tǒng)替代原生事件
      this._bindCanvasEvents();
    },

    _bindCanvasEvents() {
      if (!this.canvas) return;

      // 移除之前可能綁定的事件
      this.canvas.off("mouse:down");
      this.canvas.off("mouse:move");
      this.canvas.off("mouse:up");

      // 綁定 fabric 事件
      this.canvas.on("mouse:down", (e) => {
        if (!this.currentMode) return;
        this.startPoint = this.canvas.getPointer(e.e);
        this.isDrawing = true;
      });

      this.canvas.on("mouse:move", (e) => {
        if (!this.isDrawing || !this.currentMode || !this.startPoint) return;
        const pointer = this.canvas.getPointer(e.e);
        this.removePreview();
        const preview = this.createPreview(this.startPoint, pointer);
        if (preview) {
          this.canvas.add(preview);
          this.previewObject = preview;
          this.canvas.renderAll();
        }
      });

      this.canvas.on("mouse:up", (e) => {
        if (!this.isDrawing || !this.currentMode || !this.startPoint) return;
        const pointer = this.canvas.getPointer(e.e);
        const left = Math.min(this.startPoint.x, pointer.x);
        const top = Math.min(this.startPoint.y, pointer.y);
        const width = Math.abs(pointer.x - this.startPoint.x);
        const height = Math.abs(pointer.y - this.startPoint.y);

        this.removePreview();

        let newObject = null;

        if (this.currentMode === "rect") {
          if (width >= 5 && height >= 5) {
            newObject = this.createRect(left, top, width, height);
          }
        } else if (this.currentMode === "arrow") {
          if (Math.hypot(width, height) >= 10) {
            newObject = this.createArrow(
              this.startPoint.x,
              this.startPoint.y,
              pointer.x,
              pointer.y
            );
          }
        } else if (this.currentMode === "text") {
          newObject = this.createText(pointer.x, pointer.y);
        }

        if (newObject) {
          this.canvas.add(newObject);
          this.canvas.setActiveObject(newObject);
          this.canvas.renderAll();

          if (this.currentMode === "text" && newObject.enterEditing) {
            newObject.enterEditing();
            newObject.selectAll();
          }
        }

        this.setMode(null);
        this.isDrawing = false;
        this.startPoint = null;
      });

      // 監(jiān)聽對象添加,記錄歷史
      this.canvas.on("object:added", () => {
        this.historyStack.push("added");
      });
    },

    // ---------- 加載當前圖片 ----------
    loadCurrentImage() {
      const url = this.imgSrc;
      if (!url) {
        console.warn("沒有圖片地址");
        return;
      }

      this.loading = true;
      this.loadError = false;
      this.errorDetail = "";

      // 清空畫布但保留背景色
      if (this.canvas) {
        this.canvas.clear();
        this.canvas.backgroundColor = "#1e1e1e";
        this.canvas.renderAll();
      }

      this.loadWithImageElement(url);
    },

    loadWithImageElement(url) {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.referrerPolicy = "no-referrer";
      img.src = url;

      img.onload = () => {
        this.loading = false;
        this.onImageLoaded(img);
      };

      img.onerror = (err) => {
        console.error("圖片加載失敗:", err, url);
        this.loading = false;
        this.loadError = true;
        this.errorDetail =
          "服務器未返回 CORS 頭,請聯(lián)系圖片提供商或使用同源圖片";
      };
    },

    onImageLoaded(img) {
      if (!this.canvas) {
        console.error("canvas 實例不存在");
        return;
      }

      try {
        const fabricImg = new fabric.Image(img);
        const scale = Math.min(
          this.canvas.width / fabricImg.width,
          this.canvas.height / fabricImg.height,
          1
        );
        fabricImg.scale(scale);
        fabricImg.set({
          left: (this.canvas.width - fabricImg.width * scale) / 2,
          top: (this.canvas.height - fabricImg.height * scale) / 2,
          hasControls: false,
          selectable: false,
          evented: false,
          lockMovementX: true,
          lockMovementY: true,
        });

        this.canvas.clear();
        this.canvas.add(fabricImg);

        // 置底操作
        if (typeof this.canvas.moveTo === "function") {
          this.canvas.moveTo(fabricImg, 0);
        } else if (typeof this.canvas.sendToBack === "function") {
          this.canvas.sendToBack(fabricImg);
        }

        this.canvas.renderAll();
        this.historyStack = [];
      } catch (e) {
        console.error("圖片渲染失敗", e);
        this.loadError = true;
        this.errorDetail = "圖片渲染失敗: " + e.message;
      }
    },

    retryLoad() {
      this.loadCurrentImage();
    },

    // ---------- 繪制模式控制 ----------
    setMode(mode) {
      if (mode === null || this.currentMode === mode) {
        this.currentMode = null;
        if (this.canvas) {
          this.canvas.selection = true;
          this.canvas.skipTargetFind = false;
          this.canvas.defaultCursor = "default";
        }
        this.removePreview();
        return;
      }
      this.currentMode = mode;
      if (this.canvas) {
        this.canvas.selection = false;
        this.canvas.skipTargetFind = true;
        // 設置十字光標提示用戶正在繪制
        this.canvas.defaultCursor = "crosshair";
      }
      this.removePreview();
    },

    // ---------- 預覽對象管理 ----------
    removePreview() {
      if (this.previewObject && this.canvas) {
        this.canvas.remove(this.previewObject);
        this.previewObject = null;
      }
    },

    createPreview(start, end) {
      if (!this.canvas || !this.currentMode) return null;
      const left = Math.min(start.x, end.x);
      const top = Math.min(start.y, end.y);
      const width = Math.abs(end.x - start.x);
      const height = Math.abs(end.y - start.y);
      if (width < 2 || height < 2) return null;

      if (this.currentMode === "rect") {
        return new fabric.Rect({
          left,
          top,
          width,
          height,
          fill: "transparent",
          stroke: "#1890ff",
          strokeWidth: 3,
          strokeDashArray: [5, 5],
          selectable: false,
          evented: false,
          hasControls: false,
          hasBorders: false,
        });
      }
      if (this.currentMode === "arrow") {
        const angle = Math.atan2(end.y - start.y, end.x - start.x);
        const length = Math.hypot(width, height);
        if (length < 10) return null;
        const line = new fabric.Line([start.x, start.y, end.x, end.y], {
          stroke: "#1890ff",
          strokeWidth: 3,
          strokeDashArray: [5, 5],
          selectable: false,
          evented: false,
        });
        const triangle = new fabric.Triangle({
          left: end.x,
          top: end.y,
          width: 10,
          height: 10,
          angle: (angle * 180) / Math.PI + 90,
          fill: "#1890ff",
          originX: "center",
          originY: "center",
          selectable: false,
          evented: false,
        });
        return new fabric.Group([line, triangle], {
          selectable: false,
          evented: false,
        });
      }
      return null;
    },

    // ---------- 標注對象工廠 ----------
    createRect(left, top, width, height) {
      return new fabric.Rect({
        left,
        top,
        width,
        height,
        fill: "transparent",
        stroke: "#ff4d4f",
        strokeWidth: 3,
        strokeUniform: true,
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
      });
    },

    createArrow(x1, y1, x2, y2) {
      const angle = Math.atan2(y2 - y1, x2 - x1) * (180 / Math.PI);
      const length = Math.hypot(x2 - x1, y2 - y1);
      const line = new fabric.Line([0, 0, length - 15, 0], {
        stroke: "#ff4d4f",
        strokeWidth: 3,
        originX: "center",
        originY: "center",
      });
      const triangle = new fabric.Triangle({
        left: length - 7,
        top: 0,
        width: 12,
        height: 12,
        angle: 90,
        fill: "#ff4d4f",
        stroke: "#ff4d4f",
        strokeWidth: 2,
        originX: "center",
        originY: "center",
      });
      return new fabric.Group([line, triangle], {
        left: x1,
        top: y1,
        angle,
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
      });
    },

    createText(left, top) {
      return new fabric.IText("", {
        // 添加默認提示文字
        left,
        top,
        fontSize: 20,
        fill: "#ff4d4f",
        backgroundColor: "rgba(255,255,255,0.8)",
        hasControls: true,
        hasBorders: true,
        cornerColor: "#1890ff",
        cornerSize: 8,
        transparentCorners: false,
        padding: 4,
      });
    },

    // ---------- 保存圖片 ----------
    saveImage() {
      if (!this.canvas) {
        this.$message.error("畫布未初始化");
        return;
      }

      try {
        this.canvas.getElement().toDataURL();
      } catch (e) {
        this.$message.error(
          "畫布已被跨域圖片污染,無法導出。請使用支持 CORS 的圖片。"
        );
        return;
      }

      const canvasEl = this.canvas.getElement();
      canvasEl.toBlob(
        (blob) => {
          if (!blob) {
            this.$message.error("導出失敗,請重試");
            return;
          }
          const file = new File([blob], `annotated-${Date.now()}.png`, {
            type: "image/png",
          });
          this.$emit("save", file);
          console.log(file);
        },
        "image/png",
        1
      );
    },

    // ---------- 工具欄操作 ----------
    undo() {
      if (!this.canvas) return;
      const objects = this.canvas.getObjects();
      if (objects.length > 1) {
        this.canvas.remove(objects[objects.length - 1]);
        this.historyStack.pop();
        this.canvas.renderAll();
      }
    },

    clearAnnotations() {
      if (!this.canvas) return;
      const objects = this.canvas.getObjects();
      objects.forEach((obj, index) => {
        if (index !== 0) {
          this.canvas.remove(obj);
        }
      });
      this.historyStack = [];
      this.canvas.renderAll();
    },

    // ---------- 彈窗關閉清理 ----------
    handleClosed() {
      console.log("清理畫布資源...");

      // 1. 解綁 fabric 事件
      if (this.canvas) {
        try {
          this.canvas.off("mouse:down");
          this.canvas.off("mouse:move");
          this.canvas.off("mouse:up");
          this.canvas.off("object:added");
        } catch (e) {
          // 忽略
        }
      }

      // 2. 銷毀 fabric 實例
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷毀 canvas 失敗", e);
        }
        this.canvas = null;
      }

      // 3. 重置所有狀態(tài)
      this.currentMode = null;
      this.isDrawing = false;
      this.loadError = false;
      this.loading = false;
      this.historyStack = [];
      this.previewObject = null;
      this.startPoint = null;

      this.$emit("closed");
    },
  },
};
</script>

<style lang="scss" scoped>
.image-annotator-dialog {
  ::v-deep .el-dialog__body {
    padding: 0;
    height: calc(100vh - 215px);
    overflow: hidden;
  }
}
.annotator-container {
  display: flex;
  height: calc(100% - 40px);
  background-color: #1e1e1e;
}
.toolbar {
  width: 60px;
  background: #2b2b2b;
  padding: 12px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  color: #fff;
  .tool-item {
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 8px 0;
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.2s;
    i {
      font-size: 20px;
      color: #ddd;
    }
    &:hover {
      background: #3a3a3a;
    }
    &.active {
      background: #1890ff;
      i {
        color: white;
      }
    }
  }
  .el-divider {
    width: 80%;
    background-color: #444;
    margin: 12px 0;
  }
}
.canvas-wrapper {
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: auto;
  background: #2d2d2d;
  position: relative;
}
.load-error,
.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: #aaa;
  background: rgba(0, 0, 0, 0.7);
  padding: 30px 40px;
  border-radius: 8px;
  i {
    font-size: 48px;
    margin-bottom: 16px;
  }
  p {
    margin-bottom: 16px;
  }
  .error-detail {
    font-size: 12px;
    color: #ff7875;
    margin-bottom: 16px;
  }
}
.footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 20px;
  background: #fff;
  border-top: 1px solid #e4e7ed;
  .footer-right {
    display: flex;
    align-items: center;
    gap: 12px;
  }
}
.tips {
  color: #999;
  color: #f29f11;
  font-weight: bold;
  padding-left: 15px;
  line-height: 30px;
}
</style>

以上就是使用Vue與Fabric.js開發(fā)一個圖片標注工具的詳細內容,更多關于Vue Fabric.js圖片標注工具的資料請關注腳本之家其它相關文章!

相關文章

  • 前端vue中文件下載的三種方式匯總

    前端vue中文件下載的三種方式匯總

    對于Vue中實現(xiàn)一般的下載功能很簡單,下面這篇文章主要給大家介紹了關于前端vue中文件下載的三種方式,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下
    2022-02-02
  • 一文搞懂Vue中watch偵聽器的用法

    一文搞懂Vue中watch偵聽器的用法

    在Vue.js中,您可以使用watch選項來創(chuàng)建偵聽器,以偵聽特定屬性的變化,偵聽器可以在屬性發(fā)生變化時執(zhí)行相關的邏輯,本文給大家詳細講講Vue中watch偵聽器的用法,需要的朋友可以參考下
    2023-11-11
  • vue2.0使用md-edit編輯器的過程

    vue2.0使用md-edit編輯器的過程

    這篇文章主要介紹了vue2.0+使用md-edit編輯器的解決方案,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友參考下吧
    2024-02-02
  • Vue3插槽Slot實現(xiàn)原理詳解

    Vue3插槽Slot實現(xiàn)原理詳解

    這篇文章主要為大家介紹了Vue3插槽Slot實現(xiàn)原理詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪
    2022-07-07
  • 在Vue組件化中利用axios處理ajax請求的使用方法

    在Vue組件化中利用axios處理ajax請求的使用方法

    這篇文章主要給大家介紹了在Vue組件化中利用axios處理ajax請求的使用方法,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面跟著小編來一起學習學習吧。
    2017-08-08
  • vue項目下載文件重命名監(jiān)測進度demo

    vue項目下載文件重命名監(jiān)測進度demo

    這篇文章主要為大家介紹了vue項目下載文件重命名監(jiān)測進度demo,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪
    2023-10-10
  • vue+高德地圖寫地圖選址組件的方法

    vue+高德地圖寫地圖選址組件的方法

    這篇文章主要介紹了vue+高德地圖寫地圖選址組件的方法,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧
    2019-05-05
  • 在Vue3中實現(xiàn)拖拽文件上傳功能的過程詳解

    在Vue3中實現(xiàn)拖拽文件上傳功能的過程詳解

    文件上傳是我們在開發(fā)Web應用時經常遇到的功能之一,為了提升用戶體驗,我們可以利用HTML5的拖放API來實現(xiàn)拖拽文件上傳的功能,本文將介紹如何在Vue3中實現(xiàn)這一功能,文中有詳細的代碼示例供大家參考,需要的朋友可以參考下
    2023-12-12
  • element-plus結合sortablejs實現(xiàn)table行拖拽效果

    element-plus結合sortablejs實現(xiàn)table行拖拽效果

    使用element-plus的el-table組件創(chuàng)建出來的table,結合sortable.js實現(xiàn)table行拖動排序,文中有詳細的代碼示例供大家參考,具有一定的參考價值,感興趣的同學可以自己動手試一試
    2023-10-10
  • element-plus 下拉框實現(xiàn)全選的示例代碼

    element-plus 下拉框實現(xiàn)全選的示例代碼

    本文主要介紹了element-plus 下拉框實現(xiàn)全選的示例代碼,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2022-05-05

最新評論

桐城市| 万年县| 抚宁县| 白河县| 区。| 察隅县| 宁明县| 商都县| 十堰市| 绩溪县| 丹凤县| 石景山区| 江孜县| 德安县| 信丰县| 新乡县| 浦北县| 沂源县| 江源县| 芮城县| 义马市| 芮城县| 灵石县| 文成县| 兰州市| 安化县| 琼海市| 安国市| 布尔津县| 崇信县| 稻城县| 岳普湖县| 五华县| 常德市| 临西县| 滨海县| 桑植县| 西吉县| 印江| 巴楚县| 浪卡子县|