阅读本文需要你有一定的 Fabric.js
基础,如果还不太了解 Fabric.js
是什么,可以阅读 《Fabric.js 从入门到膨胀》
创建基础项目
为了方便演示,我在初始化画布的时:
- 添加一个背景图,该背景图的尺寸和初始化的画布一样大。
- 默认渲染一个矩形。
- 右侧可以放大缩小画布以及删除选中矩形。
- 点击左侧可以修改标签内容。
相关API
- 删除元素的2种方法:
canvas.remove(...object)
new fabric.Control
绑定控制器删除
- 更新标签内容:
- 获取选中的矩形框点击标签更新。
- 选中标签后画布上绘制。
-
搜索框支持本地模糊匹配标签。
-
isOff字段在代码中起到判断是否绘制的作用,fabric.Object.prototype.selectable = false 在绘制时关闭选中功能前提是未选中状态。
封装myFabric类
/* eslint-disable import/no-extraneous-dependencies */
import { fabric } from 'fabric';
// 删除img
const deleteIcon =
"data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Ebene_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='595.275px' height='595.275px' viewBox='200 215 230 470' xml:space='preserve'%3E%3Ccircle style='fill:%23F44336;' cx='299.76' cy='439.067' r='218.516'/%3E%3Cg%3E%3Crect x='267.162' y='307.978' transform='matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)' style='fill:white;' width='65.545' height='262.18'/%3E%3Crect x='266.988' y='308.153' transform='matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)' style='fill:white;' width='65.544' height='262.179'/%3E%3C/g%3E%3C/svg%3E";
const deleteIconImg = document.createElement('img');
deleteIconImg.src = deleteIcon;
class myFabric {
/**
* 构造函数
* @param {object} params 构造函数参数
*/
constructor(params) {
this.canvas = null; // 画布对象
this.isOff = true; // 是否开启画画模式
this.downPoint = null; // 按下鼠标时的坐标
this.upPoint = null; // 松开鼠标时的坐标
this.label = '';
this.change = null;
this.initCanvas(params);
}
/**
* 初始化画布
* @param {object} params { imgUrl, data } 图片路径,标注数据
*/
initCanvas(params) {
const { imgUrl, data, change } = params;
this.change = change;
const img = new Image();
img.src = imgUrl;
img.onload = () => {
this.canvas = new fabric.Canvas('canvas');
fabric.Object.prototype.transparentCorners = false;
fabric.Object.prototype.cornerColor = 'white';
fabric.Object.prototype.cornerStyle = 'circle';
// console.log(this.canvas, fabric);
// 创建删除按钮
fabric.Object.prototype.controls.deleteControl = new fabric.Control({
x: 0.5,
y: -0.5,
offsetY: -16,
offsetX: 16,
cursorStyle: 'pointer',
mouseUpHandler: () => {
this.delete();
},
render: (ctx, left, top, styleOverride, fabricObject) => {
const size = 24;
ctx.save();
ctx.translate(left, top);
ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));
ctx.drawImage(deleteIconImg, -size / 2, -size / 2, size, size);
ctx.restore();
},
cornerSize: 24,
});
fabric.Image.fromURL(imgUrl, (imgs) => {
this.canvas.setBackgroundImage(imgs, this.canvas.renderAll.bind(this.canvas));
});
this.canvas.setWidth(img.width);
this.canvas.setHeight(img.height);
// 选中
this.canvas.on('selection:created', (e) => {
this.isOff = false;
// console.log('选中');
});
// 取消选中
this.canvas.on('selection:cleared', () => {
this.isOff = true;
// console.log('取消选中');
});
this.canvas.on('selection:updated', () => {
// console.log('选中updated');
});
// mouse:move
// 鼠标在画布上按下
this.canvas.on('mouse:down', (e) => {
if (this.isOff) {
fabric.Object.prototype.selectable = false;
}
// 鼠标左键按下时,将当前坐标 赋值给 downPoint。{x: xxx, y: xxx} 的格式
this.downPoint = e.absolutePointer;
// console.log('鼠标左键按下时');
});
// 松开鼠标左键时
this.canvas.on('mouse:up', (e) => {
if (this.isOff) {
fabric.Object.prototype.selectable = true;
}
// console.log('松开鼠标左键时');
// 同步外部数据
// change(this.getAll());
// 绘制矩形的模式下,才执行下面的代码
// 松开鼠标左键时,将当前坐标 赋值给 upPoint
this.upPoint = e.absolutePointer;
// 调用 创建矩形 的方法
this.createRect();
change(this.getAll());
}); // 鼠标在画布上松开
data.forEach((item) => {
this.canvas.add(this.creatGroup(item));
this.canvas.renderAll();
});
// 将矩形添加到画布上
};
}
/**
* 创建矩形和text组
* @param {object} item {x,y,w,h,r,label}
*/
creatGroup(item) {
const { top, left, width, height, angle } = item;
const rect = new fabric.Rect({
top,
left,
width,
height,
angle,
padding: 0,
strokeUniform: true,
noScaleCache: false,
stroke: this.label ? 'lightgreen' : 'red',
strokeWidth: 1,
fill: 'rgba(0,0,255,0.2)', // 填充色:透明
});
// 矩形对象
const text = new fabric.Textbox(this.label, {
top: top + height / 2.3,
left,
width,
height,
fontFamily: 'Helvetica',
fill: 'white', // 设置字体颜色
fontSize: 14,
textAlign: 'center',
});
const group = new fabric.Group([rect, text]);
return group;
}
// 创建矩形
createRect() {
if (!this.isOff) return;
// 如果点击和松开鼠标,都是在同一个坐标点,不会生成矩形
if (JSON.stringify(this.downPoint) === JSON.stringify(this.upPoint)) {
return;
}
// 创建矩形
// 矩形参数计算
const top = Math.min(this.downPoint.y, this.upPoint.y);
const left = Math.min(this.downPoint.x, this.upPoint.x);
const width = Math.abs(this.downPoint.x - this.upPoint.x);
const height = Math.abs(this.downPoint.y - this.upPoint.y);
if (width < 2 || height < 2) return;
// 将矩形添加到画布上
this.canvas.add(
this.creatGroup({
top,
left,
width,
height,
angle: 0,
label: this.label,
})
);
this.canvas.renderAll();
// 创建完矩形,清空 downPoint 和 upPoint
this.downPoint = null;
this.upPoint = null;
}
/**
* 画布缩放
* @param {Boolean} type true放大 false缩小
*/
setZoom(type) {
let scale = this.canvas.getZoom();
if (type) {
scale += 0.1;
} else {
scale -= 0.1;
}
this.canvas.setZoom(scale);
}
/**
* 画布上的元素数据
*/
getAll() {
const allTarget = this.canvas.getObjects().map((item) => ({
label: item._objects[1].text,
width: item.width,
height: item.height,
left: item.left,
top: item.top,
angle: item.angle || 0,
}));
return allTarget;
}
// 更新标注框文字
updateLabel(label) {
var active = this.canvas.getActiveObject();
if (active) {
active.item(0).set({
stroke: label ? 'lightgreen' : 'red',
});
active.item(1).set({
text: label,
});
this.canvas.renderAll();
}
// active && active.set(active._objects[1], 'text', '444');
// active._objects.
this.label = label;
}
// 删除标注框
delete() {
var active = this.canvas.getActiveObject();
if (active) {
this.canvas.remove(active);
if (active.type == 'activeSelection') {
active.getObjects().forEach((x) => this.canvas.remove(x));
this.canvas.discardActiveObject().renderAll();
}
}
}
// 销毁
dispose() {
this.canvas.dispose();
}
}
export default myFabric;
vue组件使用示列
<template>
<div class="markbox">
<!-- 操作区域 -->
<div class="markbox-left">
<div class="markbox-left-small">
<a-button shape="circle" @click="cF.setZoom(true)">
<Icon icon="ant-design:zoom-in-outlined" />
</a-button>
<a-button shape="circle" @click="cF.setZoom(false)">
<Icon icon="ant-design:zoom-out-outlined" />
</a-button>
<a-button shape="circle" @click="cF.delete()">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
</div>
<!-- 画布区域 -->
<div class="markbox-center">
<a-tabs v-model:activeKey="activeKey" @change="tabChange">
<a-tab-pane key="1" tab="全部" />
<a-tab-pane key="2" tab="有标注" force-render />
<a-tab-pane key="3" tab="无标注" />
</a-tabs>
<a-button type="primary" @click="getCfData">保存当前标注(s)</a-button>
<!-- 画布 -->
<canvas id="canvas"></canvas>
<!-- 切换区域 -->
<div class="markbox-img">
<img :src="item.url" v-for="(item, i) in imgList" :key="i" @click="beforeChange(item)" />
</div>
</div>
<!-- 标签区域 -->
<div class="markbox-right">
<a-input v-model:value="searchValue" @change="onSearch" placeholder="请输入标签" style="width: 200px">
<template #suffix>
<Icon icon="ant-design:search-outlined" :style="{ color: 'rgba(0,0,0,.25)' }" />
</template>
</a-input>
<a-divider class="markbox-right-border" />
<div class="markbox-right-tag">
<a-button :type="actionTag == item ? 'primary' : 'dashed'" v-for="item in targetList" @click="tagChange(item)" :key="item">
<Icon icon="ant-design:tag-outlined" />
{{ item }}</a-button
>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, nextTick, onMounted, reactive, toRefs } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import myFabric from '/@/utils/draw.js';
const { createMessage } = useMessage();
export default defineComponent({
setup() {
const state = reactive({
// tab
activeKey: '1',
// 搜索标签
searchValue: '',
// 选中标签
actionTag: '',
imgList: [
{
id: '333',
url: `https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fphoto%2F2009-7-30%2Fenterdesk.com-13DD143D384A30C48832CDACBC54153B.jpg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1666170017&t=b9f0ab6a04b1b2c33b55fbca22aa6def`,
markList: [
{
top: 100,
left: 300,
width: 510,
height: 100,
angle: 0,
label: '1',
},
{
top: 200,
left: 100,
width: 50,
height: 100,
angle: 0,
label: '2',
},
],
},
{
id: 1,
url: `https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fn.sinaimg.cn%2Fspider20220812%2F27%2Fw930h697%2F20220812%2F3525-535aaec9331cf54852b2c350eeb03394.jpg&refer=http%3A%2F%2Fn.sinaimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1665803609&t=4a6b257e41e2d2ae49485665b648d8aa`,
markList: [
{
top: 300,
left: 100,
width: 510,
height: 100,
angle: 0,
label: '1',
},
{
top: 200,
left: 100,
width: 50,
height: 100,
angle: 0,
label: '2',
},
],
},
],
targetList: ['car', 'person'],
originalTargetList: ['car', 'person'],
cF: null,
});
const getImgUrl = (i: number) => {
return state.imgList[i];
};
const beforeChange = (item) => {
// 如果有标签为空不能切换
if (state.cF.getAll().some((item) => !item.label)) {
createMessage.warn('所有标注必须设有标签');
return;
}
initMyFabric(item);
};
const initMyFabric = ({ url, markList }) => {
state.cF && state.cF.dispose();
state.actionTag = '';
nextTick(() => {
// 初始化画布
state.cF = new myFabric({
imgUrl: url,
data: markList,
// 监听画布操作
change: (e) => moveChange(e),
});
});
};
const moveChange = (e) => {
// console.log(e);
};
// 获取画布所有元素
const getCfData = () => {
console.log(state.cF.getAll());
};
const onSearch = () => {
// value:要查询的字符串
if (state.searchValue) {
let arr = [];
state.targetList.forEach((el) => {
if (el.indexOf(state.searchValue) >= 0) {
arr.push(el);
}
});
state.targetList = arr;
} else {
state.targetList = state.originalTargetList;
}
};
const tagChange = (val) => {
state.actionTag = val;
state.cF.updateLabel(val);
};
const tabChange = (val) => {
// 初始化
initMyFabric(state.imgList[0]);
};
onMounted(() => {
initMyFabric(state.imgList[0]);
});
return {
...toRefs(state),
tabChange,
onSearch,
moveChange,
tagChange,
getCfData,
initMyFabric,
getImgUrl,
beforeChange,
};
},
});
</script>
<style lang="scss" scoped>
/* For demo */
.markbox {
position: relative;
display: flex;
justify-content: space-between;
background-color: #fff;
}
#canvas {
}
.markbox-left {
width: 50px;
display: flex;
align-items: center;
.markbox-left-small {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff;
border: 1px solid #ddd;
border-left: none;
border-radius: 0 3px 3px 0;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 10%);
color: #979797;
font-size: 18px;
padding: 15px;
button {
width: 40px;
height: 40px;
margin: 15px;
}
}
}
.markbox-right {
position: relative;
width: 250px;
background-color: #fff;
border: 1px solid #ddd;
border-left: none;
border-radius: 0 3px 3px 0;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 10%);
color: #979797;
font-size: 18px;
padding: 15px;
z-index: 100;
.markbox-right-border {
position: absolute;
left: 0;
width: 100%;
}
.markbox-right-tag {
margin-top: 45px;
button {
width: 100%;
margin-bottom: 10px;
}
}
}
.markbox-img {
display: flex;
width: 100%;
padding: 20px 0;
img {
width: 100px;
margin-right: 10px;
}
}
</style>