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

使用Vue與Fabric.js開(kāi)發(fā)一個(gè)圖片標(biāo)注工具

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

一、技術(shù)棧簡(jiǎn)介

  • Vue.js:一個(gè)用于構(gòu)建用戶(hù)界面的漸進(jìn)式框架,它使得我們能夠輕松地構(gòu)建交互式的Web界面。
  • Fabric.js:一個(gè)強(qiáng)大的JavaScript庫(kù),它使操作HTML5 canvas元素變得更加簡(jiǎn)單。通過(guò)Fabric.js,我們可以輕松地添加、修改或刪除canvas上的對(duì)象。

二、實(shí)現(xiàn)步驟

1. 初始化Vue項(xiàng)目并安裝依賴(lài)

首先,你需要有一個(gè)Vue項(xiàng)目環(huán)境。如果你還沒(méi)有,可以通過(guò)Vue CLI快速搭建。接著,安裝fabric作為我們的繪圖庫(kù):

npm install fabric --save

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

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

<template>
  <el-dialog
    title="問(wèn)題圖片標(biāo)注"
    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">
      <!-- 左側(cè)工具欄 -->
      <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="撤銷(xiāo)" placement="right">
          <div class="tool-item" @click="undo">
            <i class="el-icon-refresh-left"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="清空標(biāo)注" 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>

      <!-- 畫(huà)布區(qū)域 -->
      <div class="canvas-wrapper" ref="canvasWrapper">
        <!--  v-show 避免 DOM 重建問(wèn)題,或使用 key 強(qiáng)制刷新 -->
        <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">
      標(biāo)記完成請(qǐng)點(diǎn)擊" <i class="el-icon-check"></i>"保存
    </div>
  </el-dialog>
</template>

3. 配置Canvas與事件綁定

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

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

      // 確保之前的 canvas 實(shí)例已徹底銷(xiāo)毀
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷(xiāo)毀舊 canvas 實(shí)例失敗", 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 畫(huà)布
      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("畫(huà)布初始化失敗,請(qǐng)刷新頁(yè)面重試");
        return;
      }

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

      // 綁定鼠標(biāo)事件 - 使用 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)聽(tīng)對(duì)象添加,記錄歷史
      this.canvas.on("object:added", () => {
        this.historyStack.push("added");
      });
    },

4. 支持多種標(biāo)注類(lèi)型

  • 矩形框選:用戶(hù)可以通過(guò)拖動(dòng)鼠標(biāo)來(lái)選擇圖片中的特定區(qū)域。
  • 箭頭繪制:支持從一點(diǎn)到另一點(diǎn)繪制箭頭,方便指示或強(qiáng)調(diào)內(nèi)容。
  • 文本添加:用戶(hù)可以直接在圖片上添加說(shuō)明性文字。
    // ---------- 標(biāo)注對(duì)象工廠 ----------
    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("", {
        // 添加默認(rèn)提示文字
        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. 圖片加載與錯(cuò)誤處理

為了增強(qiáng)用戶(hù)體驗(yàn),我們加入了加載提示和錯(cuò)誤處理機(jī)制。當(dāng)圖片加載失敗時(shí),會(huì)顯示友好的錯(cuò)誤消息,并提供重試選項(xiàng)。

    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 =
          "服務(wù)器未返回 CORS 頭,請(qǐng)聯(lián)系圖片提供商或使用同源圖片";
      };
    },

    onImageLoaded(img) {
      if (!this.canvas) {
        console.error("canvas 實(shí)例不存在");
        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. 清除標(biāo)注與撤銷(xiāo)操作

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

 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();
      }
    },

三、總結(jié)

通過(guò)上述步驟,我們構(gòu)建了一個(gè)基本但功能齊全的圖片標(biāo)注工具。當(dāng)然,這只是一個(gè)起點(diǎn)。你可以根據(jù)自己的需求進(jìn)一步擴(kuò)展此工具,比如增加更多種類(lèi)的標(biāo)注工具、優(yōu)化UI設(shè)計(jì)或是集成到更大的項(xiàng)目中去。

四、完整組件

<template>
  <el-dialog
    title="問(wèn)題圖片標(biāo)注"
    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">
      <!-- 左側(cè)工具欄 -->
      <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="撤銷(xiāo)" placement="right">
          <div class="tool-item" @click="undo">
            <i class="el-icon-refresh-left"></i>
          </div>
        </el-tooltip>
        <el-tooltip content="清空標(biāo)注" 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>

      <!-- 畫(huà)布區(qū)域 -->
      <div class="canvas-wrapper" ref="canvasWrapper">
        <!--  v-show 避免 DOM 重建問(wèn)題,或使用 key 強(qiáng)制刷新 -->
        <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">
      標(biāo)記完成請(qǐng)點(diǎn)擊" <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 用于強(qiáng)制刷新 canvas DOM
      canvasKey: 0,
    };
  },
  watch: {
    visible(val) {
      this.dialogVisible = val;
    },
    dialogVisible(val) {
      this.$emit("update:visible", val);
      if (val) {
        // 每次打開(kāi)時(shí)更新 key,確保 canvas DOM 是全新的
        this.canvasKey += 1;
        this.$nextTick(() => {
          // 確保 DOM 更新后再初始化
          setTimeout(() => {
            this.initCanvas();
          }, 50);
        });
      }
    },
  },
  beforeDestroy() {
    this.handleClosed();
  },
  methods: {
    // ---------- 畫(huà)布初始化 ----------
    initCanvas() {
      // 確保 canvas DOM 已就緒
      if (!this.$refs.fabricCanvas) {
        console.warn("Canvas DOM 未就緒,等待重試...");
        setTimeout(() => this.initCanvas(), 50);
        return;
      }

      // 確保之前的 canvas 實(shí)例已徹底銷(xiāo)毀
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷(xiāo)毀舊 canvas 實(shí)例失敗", 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 畫(huà)布
      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("畫(huà)布初始化失敗,請(qǐng)刷新頁(yè)面重試");
        return;
      }

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

      // 綁定鼠標(biāo)事件 - 使用 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)聽(tīng)對(duì)象添加,記錄歷史
      this.canvas.on("object:added", () => {
        this.historyStack.push("added");
      });
    },

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

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

      // 清空畫(huà)布但保留背景色
      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 =
          "服務(wù)器未返回 CORS 頭,請(qǐng)聯(lián)系圖片提供商或使用同源圖片";
      };
    },

    onImageLoaded(img) {
      if (!this.canvas) {
        console.error("canvas 實(shí)例不存在");
        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;
        // 設(shè)置十字光標(biāo)提示用戶(hù)正在繪制
        this.canvas.defaultCursor = "crosshair";
      }
      this.removePreview();
    },

    // ---------- 預(yù)覽對(duì)象管理 ----------
    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;
    },

    // ---------- 標(biāo)注對(duì)象工廠 ----------
    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("", {
        // 添加默認(rèn)提示文字
        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("畫(huà)布未初始化");
        return;
      }

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

      const canvasEl = this.canvas.getElement();
      canvasEl.toBlob(
        (blob) => {
          if (!blob) {
            this.$message.error("導(dǎo)出失敗,請(qǐng)重試");
            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();
    },

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

      // 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. 銷(xiāo)毀 fabric 實(shí)例
      if (this.canvas) {
        try {
          this.canvas.dispose();
        } catch (e) {
          console.warn("銷(xiāo)毀 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開(kāi)發(fā)一個(gè)圖片標(biāo)注工具的詳細(xì)內(nèi)容,更多關(guān)于Vue Fabric.js圖片標(biāo)注工具的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

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

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

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

    一文搞懂Vue中watch偵聽(tīng)器的用法

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

    vue2.0使用md-edit編輯器的過(guò)程

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

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

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

    在Vue組件化中利用axios處理ajax請(qǐng)求的使用方法

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

    vue項(xiàng)目下載文件重命名監(jiān)測(cè)進(jìn)度demo

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

    vue+高德地圖寫(xiě)地圖選址組件的方法

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

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

    文件上傳是我們?cè)陂_(kāi)發(fā)Web應(yīng)用時(shí)經(jīng)常遇到的功能之一,為了提升用戶(hù)體驗(yàn),我們可以利用HTML5的拖放API來(lái)實(shí)現(xiàn)拖拽文件上傳的功能,本文將介紹如何在Vue3中實(shí)現(xiàn)這一功能,文中有詳細(xì)的代碼示例供大家參考,需要的朋友可以參考下
    2023-12-12
  • element-plus結(jié)合sortablejs實(shí)現(xiàn)table行拖拽效果

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

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

    element-plus 下拉框?qū)崿F(xiàn)全選的示例代碼

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

最新評(píng)論

姚安县| 水富县| 南乐县| 静海县| 鱼台县| 哈巴河县| 峨眉山市| 伽师县| 依安县| 绥中县| 兴城市| 昌平区| 阿合奇县| 芜湖县| 石台县| 西华县| 龙海市| 郑州市| 南平市| 安溪县| 桐庐县| 青铜峡市| 铁岭县| 灵台县| 武山县| 宣威市| 珠海市| 长葛市| 顺平县| 德格县| 岳西县| 称多县| 库尔勒市| 措美县| 武强县| 岑溪市| 铜鼓县| 昆明市| 太湖县| 株洲市| 易门县|