要做什么
实现一个方便前端使用的标注组件,作为组件一定是使用起来越简单越好,输入更少的参数,而能获取到正确的输出数据。
当我们要实现标注功能的时候,最基本的需求,一支持切换不同的画框类型,比如画框、画点、多边形。二可以满足画框,删除框甚至移动框和改变框的功能。三当标注完成的时候,可以输出所有的框的数据。
怎么做
使用vite来构建工具包,开发一个基于vue3和typescript的组件,画框工具库使用fabricjs。fabricjs提供各种类,比如一个fabric.Canvas实例代表一个画布,可以在画布实例上添加不同的对象,可以是图片对象,也可以是内部支持的矩形或者其他画框类型的对象,也可以基于已有类型开发自定义类型,比如基于当前类型添加标签的绘制。
1.搭建项目
2.基于fabric开发基础的工具类
3.实现画布组件
4.打包发布
5.在项目中使用
搭建npm包项目
使用vite官方文档提到的npm create命令创建vue项目,选择支持typescript。
首先需要确认新建的项目名称在npm中是不存在的。
npm create vite@latest project-name --template vue
在根目录下添加packages文件夹作为包的目录,把src改为examples,删除不需要的assets文件夹和components文件夹。现在文件目录如下
examples
App.vue
main.ts
style.css
vite-env.d.ts
packages
public
.giignore
index.html
package.json
README.md
tsconfig.app.json
tsconfig.json
tsconfig.node.json
vite.config.ts
接下来第二步将在packages文件夹开发我们的插件。
基于fabric开发基础的fabric工具类
这个工具类需要实现的是,输入参数画布dom对象和相关配置参数,返回一个实例,通过实例方法,比如修改配置参数,可以切换不同类型的框,通过实例方法可以获取到当前的画框数据。
输入输出有了,然后就是内部实现,根据fabricjs的特性,实现思路是监听鼠标键盘事件,根据不同类型判断是否画完了,如果没有画完,就在画布上画未完成的比如类型为折线时已有的折线,如果完成,则给实例对象添加一个画框对象。
当获取数据时,调用fabricjs内部提供的方法获取所有除了图片以外的对象。
篇幅原因下面是主要代码,详细请前往github查看。
class Annotator {
private _canvasEl // 画布dom元素
private _fabricCanvas // 画布
private _canvasContainer // 画布dom父级元素
private _mouseStatus // 鼠标状态
private _keyStatus // 键盘状态
private _drawStatus // 绘制状态
private _toolStatus // 工具状态
private _supportTypes // 支持的画框类型
...
// 画框信息列表
get frameInfo() {
return this.parseData(this._fabricCanvas.getObjects())
}
// 解析画布数据
parseData(objs, isEmpty?)
// 初始化画布
_initCanvas(el, options) {
this._fabricCanvas = new fabric.Canvas(el, {
imageSmoothingEnabled: false,
includeDefaultValues: false,
selection: false,
enableRetinaScaling: false,
...options
})
this.fabricCanvas = this._fabricCanvas
}
...
// 画布的事件监听
_addCanvasEventListener() {
const canvas = this._fabricCanvas,
mouseStatus = this._mouseStatus,
drawStatus = this._drawStatus,
keyStatus = this._keyStatus,
toolStatus = this._toolStatus
fabric.util.addListener(this._canvasContainer, 'mousewheel'...
fabric.util.addListener(document.body, 'keydown',...
fabric.util.addListener(document.body, 'keyup'...
// 移动画布
canvas.on('mouse:down', options => {......
// 绑定画布操作事件
canvas.on('mouse:down:before', options => {......
canvas.on('mouse:move', options => {
canvas.on('mouse:out', () => {
canvas.on('mouse:up:before', options => {
canvas.on('mouse:up', () => {
// 画框选中操作
canvas.on('selection:created', options => {
canvas.on('selection:updated', options => {
canvas.on('selection:cleared', options => {
}
...
// 设置选中对象
setActiveObject(obj)
// 删除对象
removeObj(obj)
// 根据工具类型画框
draw(option, type)
// 删除选中的框
deleteActive()
// 为选中的框添加标签
addLabel(labelName)
....
}
实现画框组件
组件开发同样先从输入和输出开始,从组件角度考虑,图片链接是需要外部提供的,回显的画框数据是需要外部提供的,还有画布的其他状态设置。输入容易实现,难的是输出,如果是通过vue的事件emit的话,就有点耦合了,所以决定提供一个全局的服务。
类似于vuex和vue-router,全局provide一个服务实例,在用到的组件中inject。
这个服务维护一个eventBus和当前的画布状态,eventBus提供一些方法如选中画框添加标签,删除当前画框,设置选中画框,获取画框数据等。
首先实现一个eventBus类
// eventBus.ts
type fn = (obj: object | string) => void
interface Events {
[eventName: string]: Array<fn>;
}
export interface EventBus {
events: Events,
$emit: (eventName: string, data: object | string) => void;
$on: (eventName: string, cb: fn) => void;
$off: (eventName: string, cb: fn) => void;
$clear: () => void;
}
export class eventBus {
events = {}
$on(eventName: string, callback: fn) {
if (!this.events[eventName]) {
(this.events[eventName] as Array<fn>) = [];
}
(this.events[eventName] as Array<fn>).push(callback);
}
$emit(eventName: string, data) {
if (this.events[eventName]) {
(this.events[eventName] as Array<fn>).forEach((callback: fn) => callback(data));
}
}
$off(eventName: string, callback: fn) {
if (!this.events[eventName]) return;
(this.events[eventName] as Array<fn>) = (this.events[eventName] as Array<fn>).filter(cb => cb !== callback);
}
$clear() {
this.events = {}
}
}
画框工具的服务类,这里提供了vue插件机制,通过vue实例
app.use(createLabelToolService())
全局注册
在组件中用useLabelToolService()注入。
// labelToolService.ts
import { App, inject, reactive } from "vue";
import {eventBus as eventBusControl} from '../tools/eventBus.js'
import { LabelToolService as toolService } from "./labelToolService.type.js";
const labelToolServiceToken = '__LabelToolService__'
function createLabelToolService() {
return new LabelToolService()
}
function useLabelToolService() {
return (inject(labelToolServiceToken) as toolService)
}
class LabelToolService {
eventBus
editorStatus
constructor() {
this.eventBus = new eventBusControl()
this.editorStatus = reactive({
toolStatus: {
referenceLine: false,
hideLabel: null,
rectArea: true,
type: '',
rotateClipStatus: {
rotate: 0
}
},
frameInfo: [],
annotator: {},
activeFrame: {}
})
}
get frameInfo() {
return this.editorStatus.annotator.frameInfo
}
get activeFrame() {
return this.editorStatus.activeFrame
}
addLabel(labelName: string) {
this.eventBus.$emit('addLabel', labelName)
}
onAddLabel(cb: (str: string | object) => void) {
this.eventBus.$on('addLabel', cb)
}
rerender(obj: object) {
this.eventBus.$emit('rerender', obj)
}
onRerender(cb: () => void) {
this.eventBus.$on('rerender', cb)
}
deleteFrame(obj: object) {
this.eventBus.$emit('deleteFrame', obj)
}
onDeleteFrame(cb: (obj: object | string) => void) {
this.eventBus.$on('deleteFrame', cb)
}
setActiveObj(obj: object) {
this.eventBus.$emit('setActiveObj', obj)
}
onSetActiveObj(obj: () => void) {
this.eventBus.$on('setActiveObj', obj)
}
clearAllEmits() {
this.eventBus.$clear()
}
setType(type) {
this.editorStatus.toolStatus.type = type
}
install(app: App) {
app.provide(labelToolServiceToken, this)
}
}
export {useLabelToolService, createLabelToolService}
准备工作做好,下一步是在开发的组件中使用service去监听这些需要组件响应的事件,比如画框类型修改。这里使用vue3的组合式api,把这一系列操作抽离出来,做为一个单独的方法使用。
// setEmiter.ts
import { onMounted, onUnmounted } from "vue";
import { LabelToolService as toolService } from "../services/labelToolService.type";
export function setLabelToolEmiter(toolService: toolService) {
const onAddLabel = (labelName: string) => {
if (toolService?.editorStatus.annotator.hasActiveObject && labelName) {
toolService.editorStatus.annotator.addLabel(labelName)
}
}
const onRender = () => {
toolService?.editorStatus.annotator.rerender()
}
const onDeleteFrame = (obj: object) => {
toolService?.editorStatus.annotator.removeObj(obj)
toolService?.editorStatus.annotator.rerender()
}
const onSetActiveObj = (obj: object) => {
if (toolService?.editorStatus.annotator.currentActiveObject) {
toolService.editorStatus.annotator.directiveObj(obj)
}
if (obj) {
toolService?.editorStatus.annotator.setActiveObject(obj)
}
}
const addEmiter = () => {
toolService?.onAddLabel(onAddLabel)
toolService?.onRerender(onRender)
toolService?.onDeleteFrame(onDeleteFrame)
toolService?.onSetActiveObj(onSetActiveObj)
}
onMounted(() => {
addEmiter()
})
onUnmounted(() => {
toolService?.clearAllEmits()
})
}
在组件中执行这个方法就
// canvasLabel.vue
<template>
<div class="box">
<canvas class="canvas" ref="theCanvas" id="theCanvas"></canvas>
</div>
</template>
<script setup lang="ts">
...
const toolService: LabelToolService = useLabelToolService()
setLabelToolEmiter(toolService)
...
</script>
接下来作为一个可以引入的包,我们提供两种引入方式,一种是把当前包作为组件库整体引入,另一种是只引入需要的组件。
在组件的同级目录添加一个index.ts,在这里提供作为vue插件机制的install方法
// index.ts
import { App } from 'vue'
import canvasLabel from './canvasLabel.vue'
canvasLabel.install = function(Vue: App) {
Vue.component('canvasLabel', canvasLabel)
}
export default canvasLabel
在包的根目录就是packages下新建app.ts,引入标注组件并抛出,同时实现整个包的作为插件的install
import { App } from 'vue'
import canvasLabel from './canvasLabel'
const comment = [
canvasLabel
]
export {
canvasLabel
}
const install = function (App: App) {
comment.forEach((item: {name: string}) => {
App.component(item.name, item)
})
}
export default install
在当前目录新建index.ts导出所有服务和组件
import vueLabel, {canvasLabel} from "./app";
import {useLabelToolService, createLabelToolService} from "./services/labelToolService";
export {
vueLabel,
canvasLabel,
useLabelToolService,
createLabelToolService,
}
打包发布
我们需要打包出什么,最主要的三个packages.json对应的属性,需要在文件中配置修改:
main:commonjs引入模块,对应*.umd.js文件;
module:esmodule引入文件对应*.js;
types:对应的类型文件;
"main": "dist/vue3-label.umd.js",
"module": "dist/vue3-label.js",
"types": "dist/lib/index.d.ts",
前两者在vite正常打包中都实现了,重要的是第三个,这里用到vite-plugin-dts插件,会根据ts文件生成对应的类型声明文件*.d.ts。
在vite中配置如下:
import vitePluginDts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
vue(),
vitePluginDts({
entryRoot: "./packages",
outDir: ["./dist/lib"],
tsconfigPath: "./tsconfig.buildts.json",
})
],
...
}
接下来执行默认vite生成的build命令打包
npm run build
接着在本地试用构建好的npm包,npm link 在本地试用存在各种各样的问题,这里使用yalc工具。执行之后就可以在项目中使用了,具体用法可参考项目examples目录。
// 全局安装 yalc
npm install yalc -g
// 在npm包目录下执行
yalc publish
// 在要使用的项目中执行
yalc add vue3-label
// 删除link饮用
yalc remove vue3-label / yalc remove --all
// 推送远程npm,在vue3-label包的根目录下执行,有时候需要多执行几次才会成功
yalc push / npm publish
在项目中使用
在项目中安装依赖包
npm install vue3-label
在全局引入canvasLabel组件
// main.js
import {vueLabel, createLabelToolService} from 'vue3-label'
import 'vue3-label/style.css'
// 全局服务,用来获取当前的画框数据和一些全局监听事件
const labelToolService = createLabelToolService()
createApp(App).use(labelToolService).use(vueLabel).mount('#app')
在vue组件中使用
<canvasLabel
:existingFrameInfo="state.existingFrameInfo" // 画框信息,可用于回显,需与输出信息格式保持一致
:imageUrl="state.imageUrl" // 当前要标注的图片
:onlySelected="false" // 是否只可选择,是则不能进行画框操作
></canvasLabel>
设置画布状态
import {useLabelToolService} from 'vue3-label'
const toolService = useLabelToolService()
// 支持的画框类型 'rect' | 'polygon' | 'point' | 'brush' | 'polyline'
toolService.editorStatus.toolStatus.type = 'ract'
// 获取画框信息
toolService.editorStatus.annotator.frameInfo
// 给当前选中画框添加标签
toolService.addLabel(<labelName>)
// 删除选中画框
toolService.deleteFrame(toolService.editorStatus.annotator.currentActiveObject)
项目git地址:github.com/chaikd/vue3…