Fabric.js 图形标注

需求分析

画布中显示需要标注的图片,鼠标绘制矩形进行标注(矩形绘制在图片需要标注的位置,矩形中显示标注的内容文字)。最后可以拿到标注的内容位置信息、标注信息等并且回显所有标注内容。

效果展示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4QQvIGlZ-1667875228046)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e9185752f2554bdda4efede9a6e7ba60~tplv-k3u1fbpfcp-watermark.image?)]

使用的技术

vue.js(vue2) + fabric.js

实现代码

<template>
  <div id="fabricCanvas">
    <div id="pic-label">
      <div class="canvasDraw">
        <el-button @click="getData">保存修改</el-button>
        <div class="context__x">
          <canvas ref="canvas" id="labelCanvas"> </canvas>
          <!-- 编辑和删除弹窗 -->
          <div
            id="menu"
            class="menu-x"
            v-show="showCon"
            :style="menuPosition"
            @contextmenu.prevent=""
            ref="menu"
          >
            <div>
              <ul>
                <li v-for="(item, index) in tagData" @click="changeTag(item)">
                  {{ item.value }}
                </li>
              </ul>
            </div>
            <div class="del" @click="delEl">删除</div>
          </div>
        </div>
      </div>
      <div class="tagCon">
        <div class="tagTitle" v-show="!isAdd">
          <div>标签栏</div>
          <el-button type="primary" size="small" @click="addTag"
            >添加标签</el-button
          >
        </div>
        <div class="tagDOM tagItem" v-show="isAdd">
          <el-input
            v-model="tagCon"
            ref="addTask"
            @keyup.enter.native="addNewTag"
          ></el-input>
          <el-button type="text" size="small" @click="addNewTag"
            >确定</el-button
          >
          <el-button type="text" size="small" @click="cancelAdd"
            >取消</el-button
          >
        </div>
        <!-- 搜索 -->
        <div style="margin-top: 10px">
          <el-input placeholder="请输入"></el-input>
        </div>
        <ul style="margin-top: 15px">
          <li v-for="(item, index) in tagData" class="tagItem">
            <div v-show="!item.isEdit" class="tagDOM">
              <el-tooltip
                class="item"
                effect="dark"
                :content="item.value"
                placement="right-end"
              >
                <span @click="changeTag(item)" class="tagName">
                  {{ item.value }}
                </span>
              </el-tooltip>
              <div class="iconCon">
                <i
                  class="el-icon-edit editIcon"
                  @click="changeEdit(item, index)"
                ></i>
                <i class="el-icon-delete delIcon" @click="delTag(item)"></i>
              </div>
            </div>
            <div class="tagDOM" v-show="item.isEdit">
              <el-input
                v-model="item.value"
                ref="editTask"
                @keyup.enter.native="changeText(item)"
              ></el-input>
              <el-button type="text" size="small" @click="changeText(item)"
                >确定</el-button
              >
              <el-button type="text" size="small" @click="cancelChange(item)"
                >取消</el-button
              >
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import { fabric } from "fabric";
import { uuid } from "vue-uuid";
export default {
  name: "",
  data() {
    return {
      canvasInfo: {
        width: "",
        height: "",
      },
      editorCanvas: "",
      mouseFrom: {},
      mouseTo: {},
      showCon: false,
      drawingObject: null,
      currentTarget: null,
      menuPosition: null,
      rectId: "",
      activeEl: "",
      isDrawing: false,
      currentType: "rect",
      // 标签栏
      isAdd: false,
      tagCon: "",
      tagData: [
        {
          value: "电视",
          id: "1",
          isEdit: false,
        },
        // {
        //   value: "电视柜",
        //   id: "2",
        //   isEdit: false,
        // },
        // {
        //   value: "灯",
        //   id: "3",
        //   isEdit: false,
        // },
      ],
    };
  },
  mounted() {
    // 后端返回:图片的长宽 2560 1200 ,用于等比例缩放图片
    this.canvasInfo.width = 2560 / 2;
    this.canvasInfo.height = 1200 / 2;
    this.init();

    // 监听键盘时间,按下backspace进行删除
    document.onkeydown = (e) => {
      let key = window.event.keyCode;
      // console.log("key", key);
      const isEdit = this.tagData.every((item) => item.isEdit == false);
      console.log("isEdit", isEdit);
      if (key == 8 && isEdit) {
        this.backSpaceDel();
      }
    };
  },
  methods: {
    // 按下backspace进行删除
    backSpaceDel() {
      // console.log("item", this.activeEl);
      // this.activeEl选中的标注内容
      if (this.activeEl) {
        this.editorCanvas.getObjects().forEach((item) => {
          console.log("item", item);
          if (item.rectId == this.activeEl.rectId) {
            this.editorCanvas.remove(item);
          }
        });
        this.editorCanvas.requestRenderAll();
      }
    },
    // 删除标签栏的tag---(实际项目中配合联调接口删除)
    delTag(item) {
      this.$confirm(`此操作将永久删除标签${item.value}, 是否继续?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      })
        .then(() => {
          item.isEdit = false;
          let text = item.value;
          this.editorCanvas.getObjects().forEach((item1) => {
            console.log("item111--delTag", item1.textID, item.id);
            if (item1.textID && item1.textID == item.id) {
              this.editorCanvas.remove(item1);
              this.editorCanvas.requestRenderAll();
            }
          });
          // 删除标签
          this.tagData = this.tagData.filter((el) => el.id != item.id);
          console.log("tagData", this.tagData);
          this.$message({
            type: "success",
            message: "删除成功!",
          });
        })
        .catch(() => {
          this.$message({
            type: "info",
            message: "已取消删除",
          });
        });
    },
    init() {
      this.initeditorCanvas();
      this.initD();
    },
    // 初始化模板编辑画布
    initeditorCanvas() {
      // 根据canvas绘制保存的内容
      const str = JSON.parse(localStorage.getItem("canvasdata"));
      console.log("str", str);
      // 初始化canvas
      this.editorCanvas = new fabric.Canvas("labelCanvas", {
        // devicePixelRatio: true,
        width: this.canvasInfo.width, // canvas 宽
        height: this.canvasInfo.height,
        backgroundColor: "#ffffff",
        transparentCorners: false,
        fireRightClick: true, // 启用右键,button的数字为3
        stopContextMenu: true, // 禁止默认右键菜单
      });
      // this.editorCanvas.preserveObjectStacking = true;
      // this.editorCanvas.selectable = false;
      // this.editorCanvas.selection = false;
      // this.editorCanvas.toJSON(['rectId'])
      // this.editorCanvas.skipTargetFind = true;
      var img = "https://i1.mifile.cn/f/i/18/mitv4A/40/build.jpg";
      // mounted内预设的比例(由于图片太大,展示不下,实际项目中可以根据后端返回的图片大小范围去设置缩放比例)
      const scaleX = this.canvasInfo.width / 2560;
      const scaleY = this.canvasInfo.height / 1200;
      // 将图片设置成背景
      this.editorCanvas.setBackgroundImage(
        img,
        this.editorCanvas.renderAll.bind(this.editorCanvas), // 刷新画布
        {
          scaleX,
          scaleY,
          originX: "left",
          originY: "top",
          left: 0,
          top: 0,
        }
      );
      /**
       * 模型返回的绘制:根据拿到的left、top, width, height去绘制新矩形(根据图片与canvas的比例)
         drawRect()
       */
      // 监听鼠标右键的执行
      this.editorCanvas.on("mouse:down", this.canvasOnMouseDown);
      // 数据回显
      if (str) {
        // this.editorCanvas.loadFromJSON(str)
        this.editorCanvas.loadFromJSON(
          str,
          this.editorCanvas.renderAll.bind(this.editorCanvas),
          function (o, object) {
            // `o` = json object
            // `object` = fabric.Object instance
            // ... do some stuff ...
            // console.log('objqwe', o, object)
          }
        );
      }
    },
    initD() {
      this.editorCanvas.on("mouse:down", (options) => {
        // 记录当前鼠标的起点坐标
        if (!this.editorCanvas.getActiveObject()) {
          this.mouseFrom.x = options.pointer.x;
          this.mouseFrom.y = options.pointer.y;
          this.isDrawing = true;
        }
      });
      // 监听鼠标移动
      this.editorCanvas.on("mouse:move", (options) => {
        // console.log("move", options);
        if (!this.editorCanvas.getActiveObject() && this.isDrawing) {
          console.log("move");
          this.mouseTo.x =
            options.pointer.x > this.editorCanvas.width
              ? this.editorCanvas.width
              : options.pointer.x;
          this.mouseTo.y =
            options.pointer.y > this.editorCanvas.height
              ? this.editorCanvas.height
              : options.pointer.y;
        }
      });
      this.editorCanvas.on("mouse:up", (options) => {
        this.isDrawing = false;
        // console.log("mouse:up", options);
        if (
          !this.editorCanvas.getActiveObject() &&
          this.currentType == "rect"
        ) {
          // 解决绘制的时候超出边界
          this.mouseTo.x =
            options.pointer.x > this.editorCanvas.width
              ? this.editorCanvas.width
              : options.pointer.x;
          this.mouseTo.y =
            options.pointer.y > this.editorCanvas.height
              ? this.editorCanvas.height
              : options.pointer.y;

          // 宽高为负值或为0
          let width = this.mouseTo.x - this.mouseFrom.x;
          let height = this.mouseTo.y - this.mouseFrom.y;
          // 如果点击和松开鼠标,都是在同一个坐标点或者反向,不绘制矩形
          if (width <= 0 || height <= 0) return;
          this.drawRect();
        }
        this.editorCanvas.renderAll();
      });
      this.editorCanvas.on("object:moving", (e) => {
        // 边界处理
        var obj = e.target;
        // if object is too big ignore
        if (
          obj.currentHeight > obj.canvas.height ||
          obj.currentWidth > obj.canvas.width
        ) {
          return;
        }
        obj.setCoords();
        // top-left  corner
        if (obj.getBoundingRect().top < 0 || obj.getBoundingRect().left < 0) {
          obj.top = Math.max(obj.top, obj.top - obj.getBoundingRect().top);
          obj.left = Math.max(obj.left, obj.left - obj.getBoundingRect().left);
        }
        // bot-right corner
        if (
          obj.getBoundingRect().top + obj.getBoundingRect().height >
            obj.canvas.height ||
          obj.getBoundingRect().left + obj.getBoundingRect().width >
            obj.canvas.width
        ) {
          obj.top = Math.min(
            obj.top,
            obj.canvas.height -
              obj.getBoundingRect().height +
              obj.top -
              obj.getBoundingRect().top
          );
          obj.left = Math.min(
            obj.left,
            obj.canvas.width -
              obj.getBoundingRect().width +
              obj.left -
              obj.getBoundingRect().left
          );
        }
      });
      this.editorCanvas.on("object:scaling", (options) => {
        // console.log("scale", options);
        var text = options.target.item(1);
        let group = options.target;
        console.log("text", text.width, group.width, group.getScaledWidth());
        let scaleX = group.width / group.getScaledWidth();
        let scaleY = group.height / group.getScaledHeight();
        text.set({
          fontSize: 14,
          scaleX,
          scaleY,
        });
      });
    },
    // 绘制矩形
    /**
     * 绘制的原理:矩形+文字的组合
     */
    drawRect() {
      console.log("绘图啦11111", this.mouseFrom, this.mouseTo);
      // 通过UUID拿到唯一的ID
      let rectId = uuid.v1();
      /**
       * 删除之前的this.drawingObject
       */
      if (this.drawingObject) {
        this.editorCanvas.remove(this.drawingObject);
      }
      this.drawingObject = null;
      // 计算矩形长宽
      let left = this.mouseFrom.x;
      let top = this.mouseFrom.y;
      let width = this.mouseTo.x - this.mouseFrom.x;
      let height = this.mouseTo.y - this.mouseFrom.y;
      const drawingObject = new fabric.Rect({
        width: width,
        height: height,
        fill: "#d70202",
        lockRotation: true,
        opacity: 0.5,
        rectId,
        lockScalingFlip: true, // 禁止负值反转
        originX: "center",
        originY: "center",
      });
      const text = new fabric.Textbox("", {
        // width,
        // height,
        fontFamily: "Helvetica",
        fill: "white", // 设置字体颜色
        fontSize: 14,
        textAlign: "center",
        rectId,
        lockScalingX: true,
        lockScalingY: true,
        lockScalingFlip: true, // 禁止负值反转
        originX: "center",
        originY: "center",
      });
      if (drawingObject) {
        const group = new fabric.Group([drawingObject, text], {
          rectId,
          left: left,
          top: top,
          width: width,
          height: height,
          lockScalingFlip: true,
          lockRotation: true,
        });
        this.editorCanvas.add(group);
        console.log("this.editorCanvas", this.editorCanvas);
        this.editorCanvas.renderAll();
        this.drawingObject = drawingObject;
        // 绘制完成展示右键菜单栏(因为在鼠标绘制时,通过mouseup拿不到绘制的内容)
        let len = this.editorCanvas._objects.length;
        let curOptions = this.editorCanvas._objects[len - 1];
        this.showMenuCon(curOptions);
      }
    },
    // 绘制时展示右键菜单栏内容
    showMenuCon(options) {
      console.log(options);
      this.activeEl = options;
      // 当前鼠标位置
      let pointX = options.left + options.width * options.scaleX;
      let pointY = options.top;

      this.menuPosition = `
                left: ${pointX}px;
                top: ${pointY}px;
              `;
      this.showCon = true;
    },
    // 编辑
    changeEdit(item, index) {
      item.isEdit = true;
      // focus: 点击编辑聚焦
      this.$nextTick(() => this.$refs.editTask[index].focus());
      // console.log('this.$refs.editTask', this.$refs.editTask)
    },
    // 修改选中的标注内容
    changeText(item) {
      if (item.value == "") {
        this.$message({
          type: "error",
          message: "标签内容不能为空",
          offset: 200,
        });
        return;
      }
      item.isEdit = false;
      let text = item.value;
      this.editorCanvas.getObjects().forEach((item1) => {
        if (item1.textID && item1.textID == item.id) {
          console.log("item111", item1.textID, item.id);
          item1.item(1).set({
            text,
            originX: "center",
            originY: "center",
            textAlign: "center",
          });
          this.editorCanvas.requestRenderAll();
        }
      });
    },
    // 取消修改
    cancelChange(item) {
      item.isEdit = false;
    },
    // 右键菜单
    canvasOnMouseDown(options) {
      if (options.button === 3 && options.target && !options.target.rectId) {
        return;
      }
      this.activeEl = options.target;
      // console.log("opt", options);
      // 判断:右键,且在元素上右键
      // opt.button: 1-左键;2-中键;3-右键
      // 在画布上点击:opt.target 为 null
      if (options.button === 3 && options.target) {
        // 获取当前元素
        // 设置右键菜单位置
        // 右键菜单的位置
        let pointX =
          options.target.left + options.target.width * options.target.scaleX;
        let pointY = options.target.top;
        // 设置右键菜单定位
        this.menuPosition = `
                left: ${pointX}px;
                top: ${pointY}px;
              `;
        this.showCon = true;
      } else {
        this.showCon = false;
      }
    },
    // 添加标签
    addTag() {
      this.isAdd = true;
      // input鼠标聚焦
      this.$nextTick(() => this.$refs.addTask.focus());
    },
    addNewTag() {
      if (this.tagCon.trim() == "") {
        this.message({
          type: "error",
          message: "内容不能为空",
          offset: 200,
        });
        return;
      }
      // 调接口
      this.tagData.push({
        value: this.tagCon,
        id: uuid.v1(),
        isEdit: false,
      });
      // 置空 关闭
      this.tagCon = "";
      this.isAdd = false;
    },
    // 取消添加
    cancelAdd() {
      this.isAdd = false;
    },
    // 根据选中的TAG进行修改
    changeTag(el) {
      if (this.activeEl) {
        // console.log("item", el.value, this.activeEl.rectId);
        let text = el.value;
        let textID = el.id;
        this.editorCanvas.getObjects().forEach((item) => {
          // console.log("item", item);
          if (item.rectId == this.activeEl.rectId) {
            // console.log("item", item);
            item.set({
              textID,
            });
            item.item(1).set({
              text,
              originX: "center",
              originY: "center",
              textAlign: "center",
            });
          }
        });
        this.editorCanvas.requestRenderAll();
        this.showCon = false;
      }
    },
    // 删除选中的元素
    delEl() {
      this.editorCanvas.getObjects().forEach((item) => {
        console.log("item", item);
        if (item.rectId == this.activeEl.rectId) {
          this.editorCanvas.remove(item);
          // console.log(item.rectId, this.activeEl.rectId)
        }
      });
      this.editorCanvas.requestRenderAll();
      this.showCon = false;
    },
    // 拿到canvas上的所有数据
    /**
     * 最终提交给后端要说明:scaleX,scaleY
     */
    getData() {
      console.log(
        "this.editorCanvas",
        this.editorCanvas,
        this.editorCanvas.toJSON()
      );
      // rectId自定义属性
      localStorage.setItem(
        "canvasdata",
        JSON.stringify(
          this.editorCanvas.toJSON(["rectId", "textID", "lockScalingFlip"])
        )
      );
      console.log("getObjects", this.editorCanvas.getObjects());
    },
  },
};
</script>

<style lang="scss" scoped>
#fabricCanvas {
  padding: 20px;
  background: #f6f6f6;
  #pic-label {
    width: 100%;
    display: flex;
    justify-content: center;
    background: #f6f6f6;
    .canvasDraw {
      background: #fff;
      padding: 20px;
    }
    #labelCanvas {
      position: relative;
      box-shadow: 0 0 25px #cac6c6;
      width: 100%;
      display: block;
      // margin: 15px auto;
      height: 100%;

      #editDel {
        position: absolute;
        // top: 50%;
        // left: 50%;
        // transform: translate(-50%, -50%);
        width: 100px;
        height: 100px;
        line-height: 100px;
        background: red;
        color: white;
        text-align: center;
        margin: auto auto;
        z-index: 99999;
      }
    }
    .tagCon {
      width: 20%;
      margin-left: 20px;
      min-width: 250px;
      background: #fff;
      padding: 10px 20px;

      .tagTitle {
        height: 62px;
        font-size: 18px;
        font-weight: 700;
        display: flex;
        justify-content: space-between;
        align-items: center;
        // padding: 0 20px;
        box-sizing: border-box;
      }
      .tagItem {
        margin-bottom: 10px;
        border: 1px solid #dcdfe6;
        padding: 10px;
        border-left: 4px solid blue;
      }
      .tagDOM {
        display: flex;
        justify-content: space-between;
        align-items: center;
        .tagName {
          width: 70%;
          height: 40px;
          line-height: 40px;
          margin-right: 10px;
          display: inline-block;
          // width: 100px;
          text-align: left;
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
          font-size: 14px;
          color: #606266;
        }
        .iconCon {
          display: none;
          .editIcon {
            cursor: pointer;
            margin-right: 10px;
          }
          .delIcon {
            cursor: pointer;
          }
        }

        &:hover .iconCon {
          display: block;
        }
      }
    }
  }
}
</style>
<style scoped>
.context__x {
  position: relative;
  margin-top: 15px;
}

.menu-x {
  width: 200px;
  position: absolute;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}

.menu-x div {
  box-sizing: border-box;
  padding: 4px 8px;
  border-bottom: 1px solid #ccc;
  cursor: pointer;
}

.menu-x ul > li:hover {
  background-color: antiquewhite;
}

.menu-x .del:hover {
  background-color: antiquewhite;
}

.menu-x div:first-child {
  border-top-left-radius: 4px;
  border-top-right-radius: 4px;
}

.menu-x div:last-child {
  border-bottom: none;
  border-bottom-left-radius: 4px;
  border-bottom-right-radius: 4px;
}
.tagDOM >>> .el-input__inner {
  border: 0;
  padding: 0;
}
#fabricCanvas >>> .el-card__body,
.el-main {
  padding: 0;
}
</style>

学习使用Fabric

边界处理-拖拽

部分基本内容学习

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值