paper.js 实现图片简单框选标注功能
一,效果
在开发中的实际业务场景可能会更加复杂,这里只展示操作的核心代码,不包含任务业务逻辑。
二,引入并注册 paper.js, 绑定全局事件
1,安装paper.js
npm install paper 或者 yarn add paper
2,注册 paper.js ,绑定全局事件
<template><div class="layout"><canvas ref="canvas" class="canvas-content"></canvas><!-- 缩放按钮 --><div class="scale"><i class="scale-reduce scale-icon" @click="reduceView"></i><div class="scale-number">{{ (scale * 100).toFixed(0) }}%</div><i class="scale-plus scale-icon" @click="magnifyView"></i></div></div>
</template><script setup lang='ts'>
import { ref, defineProps, PropType, watch, onMounted, defineEmits, onUnmounted } from 'vue';
import paper from 'paper';const canvas = ref();
let myPaper: paper.PaperScope | null = null;// 当前缩放比例const scale = ref<number>(1);const state = {// 工具类tool: null as paper.Tool | null,
}onMounted(() => {if (myPaper) {myPaper.project.clear();myPaper = null;}if (canvas.value) {myPaper = new paper.PaperScope();// 注册画布myPaper.setup(canvas.value);state.tool = new myPaper.Tool();// 注册鼠标按下事件state.tool.onMouseDown = paperMouseDown;// 注册鼠标移动事件state.tool.onMouseMove = paperMouseMove;// 注册拖拽事件state.tool.onMouseDrag = paperMouseDrag;// 注册鼠标抬起事件state.tool.onMouseUp = paperMouseUp;}// 监听窗口大小改变事件window.addEventListener('resize', resizeHandle);});
</script>
三,初始化背景图,居中加载,缩放至适当比例
1,将背景图层及背景图路径保存在全局变量 state 中
后面代码中的state.xxxxx,默认是在 state 中已经声明好的。
const state = {// 背景图层bgLayer: null,// 背景图路径bgPath: null,// 背景图左上角顶点 x 坐标startX: 0,// 背景图左上角顶点 y 坐标startY: 0,
}
2,初始化背景图层
LayerName 是各个图层名称的枚举变量,给图层命名方便使用时直接通过名称获取需要操作的图层。
/*** @description: 初始化背景图层* @return {*}*/const initBgLayer = async () => {// 创建第一个背景图层if (!state.bgLayer) {state.bgLayer = new myPaper.Layer();state.bgLayer.name = LayerName.BG_LAYER;} else {state.bgLayer?.removeChildren();}if (!myPaper?.project.layers[LayerName.BG_LAYER as any]) {// 如果存在图层信息,则直接将图层添加到project 里myPaper?.project.addLayer(state.bgLayer);}// 设置bgLayer为活动图层// 使用 Promise 等待图片加载完成await new Promise((resolve, reject) => {state.bgPath= new myPaper.Raster({source: props.imageData.convertFileName,onLoad: () => {resolve(void 0); // 加载成功时调用 resolve},onError: () => {reject(new Error('加载失败')); // 加载失败时调用 reject},});});// 设置背景图路径名称state.bgPath.name = PATN_NAME.BG_IMAGE// 计算缩放比例calculateScale(props.imageData.imageWidth, props.imageData.imageHeight, myPaper.view.size.width, myPaper.view.size.height);// 根据图片的宽高和画布的宽高计算初始缩放比例myPaper.view.scale(scale.value);// 设置居中显示state.bgPath.position = myPaper.view.center;// 获取背景图左上角顶点坐标state.startX = state.bgPath.position.x - state.bgPath.bounds.width / 2;state.startY = state.bgPath.position.y - state.bgPath.bounds.height / 2;};
3,计算缩放比例
/*** @description: 计算缩放比例* @param {number} imageWidth* @param {number} imageHeight* @param {number} canvasWidth* @param {number} canvasHeight* @return {*}*/const calculateScale = (imageWidth: number, imageHeight: number, canvasWidth: number, canvasHeight: number) => {// 计算照片的宽高比const imageRatio = imageWidth / imageHeight;// 计算画布的宽高比const canvasRatio = canvasWidth / canvasHeight;// 如果照片的宽高比大于画布的宽高比,则根据宽度计算缩放比例if (imageRatio > canvasRatio) {// 初始缩放比例按照窗口边缘20px留白为准scale.value = (canvasWidth - 40) / imageWidth;} else {// 如果照片的宽高比小于或等于画布的宽高比,则根据高度计算缩放比例scale.value = (canvasHeight - 40) / imageHeight;}// 将缩放比例保留两位小数scale.value = parseFloat(scale.value.toFixed(2));};
四,画布缩放
1,缩小画布
/*** @description: 缩小画布* @return {*}*/const reduceView = () => {if (!myPaper) return;// 如果缩放比例小于0.1则不再缩小if (scale.value <= 0.1) {return;}// 每次缩小5%scale.value = scale.value - 0.05;// 清除画布缩放比例myPaper.view.zoom = 1;// 重新进行缩放myPaper.view.scale(scale.value);};
2,放大画布
/*** @description: 放大画布* @return {*}*/const magnifyView = () => {if (!myPaper) return;// 每次放大5%scale.value = scale.value + 0.05;// 清除画布缩放比例myPaper.view.zoom = 1;// 重新进行缩放myPaper.view.scale(scale.value);};
五,初始化选框位置
1,初始化选框图层
PATH_NAME 是图层中的所有类型的路径的名称,是一个枚举值。方便在某个 group 路径中直接找到对应的路径进行操作( 当 group 中的子路径不唯一时 )。
/*** @description: 初始化选框信息图层* @return {*}*/const initStep1Layer = () => {if (!state.step1Layer) {// 创建选框信息图层state.step1Layer = new myPaper.Layer();// 设置图层名称state.step1Layer.name = LayerName.RECT;} else {// 如果存在图层,则需要将图层中的所有路径删除state.step1Layer?.removeChildren();}// 如果当前项目中不包括框选图层,则要将框选图层添加到当前项目中if (!myPaper?.project.layers[LayerName.Rect as any]) {myPaper?.project.addLayer(state.step1Layer);}// 遍历选框信息数据绘制矩形框props.data?.forEach((item:Data) => {// 创建选框信息路径群组,可能选框会包括别的标签内容const group = new myPaper.Group();// 添加信息框选const info = new myPaper.Path.Rectangle({name: PATH_NAME.INFO,point: [item.rect[0] + state.startX, item.rect[1] + state.startY],size: [item.rect[2], item.rect[3]],data: {...item,},});// 根据步骤修改信息选框样式modifyInfoRectStyle(info, Step.STEP1);group.addChild(info);state.step1Layer?.addChild(group);});};
2,设置选框样式
根据条件或者状态的不同修改路径样式,infoStyle 是路径的样式配置对象
infoStyle
// info 选框样式
export const infoStyle = {// 默认样式default: {strokeColor: new paper.Color('#aaaaaa'),strokeWidth: 2,// 填充颜色fillColor: new paper.Color('rgba(0, 0, 0, 0.01)'),},//禁用样式disabled: {strokeColor: new paper.Color('#637176'),strokeWidth: 2,// 填充颜色fillColor: new paper.Color('rgba(0, 0, 0, 0.01)'),},
};
/*** @description: 修改信息选框样式* @param {paper.Path} path* @param {Step} activeStep* @return {*}*/const modifyInfoRectStyle = (path: paper.Path, activeStep: Step) => {if (条件a) {// 设置路径样式path.set(infoStyle.default);} else {// 否则绘制禁用状态path.set(infoStyle.disabled);}};
六,初始化操作按钮,添加点击事件
/*** @description: 初始化操作按钮图层* @return {*}*/const initOperatorLayer = () => {if (!state.operatorLayer) {state.operatorLayer = new myPaper.Layer();state.operatorLayer.name = LayerName.OPERATOR_LAYER;} else {if (!myPaper?.project.layers[LayerName.OPERATOR_LAYER as any]) {// 如果存在图层信息,则直接将图层添加到project 里myPaper?.project.addLayer(state.operatorLayer);}// 如果存在操作按钮图层,则直接退出return;}// 创建完成按钮state.finishBtn = addOperatorBtn(OperatorBtnType.FINISH);state.operatorLayer?.addChild(state.finishBtn);// 注册完成按钮点击事件state.finishBtn.onClick = finishBtnClick;state.finishBtn.onMouseMove = operatorBtnMouseMove;// 创建删除按钮state.deleteBtn = addOperatorBtn(OperatorBtnType.DELETE);state.operatorLayer?.addChild(state.deleteBtn);// 注册删除按钮点击事件state.deleteBtn.onClick = deleteBtnClick;state.deleteBtn.onMouseMove = operatorBtnMouseMove;};/*** @description: 给当前操作路径又下角添加操作按钮* @param {*} startX* @param {*} startY* @param {*} name* @return {*}*/const addOperatorBtn = (btnType: OperatorBtnType): paper.Group => {let name;if (btnType === OperatorBtnType.DELETE) {name = '删除';} else if (btnType === OperatorBtnType.FINISH) {name = '完成';}// 创建一个组合const group = new myPaper.Group();// 设置操作按钮名称,方便删除group.name = btnType;// 创建删除矩形路径const rect = new myPaper.Path.Rectangle({name: PATH_NAME.OPERATOR,point: [state.startX, state.startY],size: [operatorStyle.width, operatorStyle.height],fillColor: operatorStyle.backgroundColor,strokeColor: operatorStyle.backgroundColor,strokeWidth: 2,radius: operatorStyle.borderRadius,});group.addChild(rect);// 创建删除文本路径放置在上面的矩形路径中,字体颜色为白色const text = new myPaper.PointText({name: PATH_NAME.OPERATOR,content: name,fillColor: operatorStyle.textColor,fontSize: operatorStyle.fontSize,});text.justification = 'center';text.strokeColor = operatorStyle.textColor;text.strokeWidth = 1;// 操作按钮文本居中text.position.x = rect.bounds.topLeft.x + operatorStyle.width / 2;text.position.y = rect.bounds.topLeft.y + operatorStyle.height / 2;// 将文本路径添加进矩形路径中group.addChild(text);// 初始默认隐藏操作按钮group.visible = false;return group;};
七,新增框选,移动缩放选框全局事件处理
新增框选需要事件:mousedown,mousedrag,mouseup
移动选框需要事件:mousedown,mousedrag
缩放选框需要事件:mousedown,mousedrag
1,mousedown 获取所有需要进行碰撞检测的路径,(还有切换选框编辑状态的功能)
CurrentPathStatus 是当前路径操作的枚举值,有创建状态 CurrentPathStatus.CREATE,编辑状态 CurrentPathStatus.EDIT,默认状态 CurrentPathStatus.DEFAULT。
isCreate.value 表示当前是否可以创建选框
由于当前场景比较简单,所有需要进行碰撞检测的路径即为 state.step1Layer 图层中的所有路径
/*** @description: paper鼠标按下事件* @param {*} event* @return {*}*/const paperMouseDown = (event: paper.ToolEvent) => {// 初始化鼠标按下碰撞路径和路径顶点state.hitPath = null as paper.Path | null;state.hitSegment = null;// 鼠标点位碰撞检测const hitResult = myPaper?.project.hitTest(event.point, hitOptions);// 操作按钮有单独的点击事件,不执行一下逻辑if (hitResult?.item?.name ===PATN_NAME.OPERATOR) return;// 如果当前是创建状态,则只能点击新建选框// if (state.currentPathStatus === CurrentPathStatus.CREATE && hitResult?.item !== state.currentPath) return;if (hitResult) {state.hitPath = hitResult.item;// 点击选框设置为编辑状态if (!isCreate.value &&state.currentPathStatus === CurrentPathStatus.DEFAULT &&state.hitPath.name ===PATH_NAME.INFO &&!state.hitPath.selected ) {// 当前是默认状态时,设置选框为编辑状态switchPathEditStatus(state.hitPath as paper.Path);state.currentPath = state.hitPath as paper.Path;// 获取碰撞检测群体路径// 由于场景比较简单,不需要单独获取碰撞检测群体路径,state.step1Layer 图层中的路径即为碰撞检测路径。} else if (!isCreate.value &&state.currentPathStatus === CurrentPathStatus.EDIT &&state.hitPath?.name === PATH_NAME.INFO &&!state.hitPath.selected ) {// 当前是编辑状态,则需要将当前路径设置为默认状态,然后将点击路径设置为编辑状态,并更新state.currentPathswitchPathDefaultStatus(state.currentPath as paper.Path);// 将碰撞路径切换至编辑状态switchPathEditStatus(state.hitPath as paper.Path);// 更新当前路径state.currentPath = state.hitPath as paper.Path;// 获取碰撞检测群体路径// 由于场景比较简单,不需要单独获取碰撞检测群体路径,state.step1Layer 图层中的路径即为碰撞检测路径。}// 创建选框时,鼠标按下事件获取所有需要碰撞检测的路径if (isCreate.value &&state.hitPath?.name === PATH_NAME.BG_IMAGE) {// 获取创建选框时需要的碰撞检测路径// 由于场景比较简单,不需要单独获取碰撞检测群体路径,state.step1Layer 图层中的路径即为碰撞检测路径。} else if (state.currentPathStatus === CurrentPathStatus.CREATE && state.hitPath?.name === PATH_NAME.INFO) {// 此时是刚创建完选框时,点击拖拽移动新创建选框位置// 获取当前选框碰撞群体数据state.currentPath = state.hitPath as paper.Path;// 由于场景比较简单,不需要单独获取碰撞检测群体路径,state.step1Layer 图层中的路径即为碰撞检测路径。}if (state.currentPathStatus !== CurrentPathStatus.DEFAULT && state.hitPath?.name === PATH_NAME.INFO && hitResult.type === 'segment') {// 创建和编辑状态时可以点击选框顶点进行缩放选框大小// 记录当前点击的路径顶点state.hitSegment = hitResult.segment;}}};/*** @description: 切换路径默认状态* @param {*} path* @return {*}*/const switchPathDefaultStatus = (path: paper.Path) => {// 切换路径默认状态state.currentPathStatus = CurrentPathStatus.DEFAULT;if (path) {// 设置选框路径样式为默认状态path.set(infoStyle.default);// 设置没有选中path.selected = false;}// 隐藏操作按钮setOperatorBtnHide();};/*** @description: 设置操作按钮隐藏* @return {*}*/const setOperatorBtnHide = () => {// 隐藏操作按钮if (state.deleteBtn) state.deleteBtn.visible = false;if (state.finishBtn) state.finishBtn.visible = false;};/*** @description: 切换路径编辑状态* @param {*} path* @return {*}*/const switchPathEditStatus = (path: paper.Path) => {// 切换路径编辑状态state.currentPathStatus = CurrentPathStatus.EDIT;if (path) {// 设置选框路径样式为编辑状态path.set(infoStyle.edit);path.selectedColor = infoStyle.edit.strokeColor;// 设置选中path.selected = true;}// 移动操作按钮到路径的右下角setOperatorBtnShow(path);};/*** @description: 设置操作按钮显示* @param {*} path* @return {*}*/const setOperatorBtnShow = (path: paper.Path) => {// 如果存在删除按钮if (state.deleteBtn) {state.deleteBtn.position.x = path.bounds.bottomRight.x + operatorStyle.width / 2 + 5;state.deleteBtn.position.y = path.bounds.bottomRight.y + operatorStyle.height / 2 + 5;state.deleteBtn.visible = true;}if (state.finishBtn) {state.finishBtn.position.x = path.bounds.bottomRight.x + (operatorStyle.width / 2) * 3 + operatorStyle.margin + 5;state.finishBtn.position.y = path.bounds.bottomRight.y + operatorStyle.height / 2 + 5;state.finishBtn.visible = true;}};
2,mousedrag 当鼠标点击在 无选框区域 绘制选框,并在绘制的过程中进行碰撞检测, 移动画布,移动选框,缩放选框。
/*** @description: paper鼠标拖拽事件* @param {*} event* @return {*}*/const paperMouseDrag = (event: paper.ToolEvent) => {// 如果存在碰撞路径,且碰撞路径是背景图时,则可以创建选框if (isCreate.value &&state.hitPath &&state.hitPath.name === PATN_NAME.BG_IMAGE) {// 创建选框state.currentPathStatus = CurrentPathStatus.CREATE;state.currentPath = new myPaper.Path.Rectangle({name:PATH_NAME.INFO,point: event.downPoint,size: [event.point.x - event.downPoint.x, event.point.y - event.downPoint.y],data: {// 携带参数},});state.currentPath.set(infoStyle.edit);state.currentPath.removeOnDrag();// 碰撞检测if (isHit(state.currentPath as paper.Path)) {// 如果发生碰撞,删除当前路径state.currentPath.remove();state.currentPath = new myPaper.Path.Rectangle({name: PATH_NAME.INFO,point: event.downPoint,size: state.lastRectSize,data: {// 携带参数},});state.currentPath.set(infoStyle.edit);state.currentPath.removeOnDrag();} else {// 如果没发生碰撞,保存当前选框尺寸大小state.lastRectSize = [event.point.x - event.downPoint.x, event.point.y - event.downPoint.y];}} else if (state.hitSegment) {// 缩放选框moveEditPathSegment(Operator.ADD, event.delta);// 缩放过程中进行碰撞检测if (isHit(state.currentPath as paper.Path)) {// 如果发生碰撞,则移动回原来位置moveEditPathSegment(Operator.SUBTRACT, event.delta);}} else if (state.currentPathStatus !== CurrentPathStatus.DEFAULT && state.hitPath?.name ===PATH_NAME.INFO && state.hitPath?.selected) {// 移动选框moveEditPath(Operator.ADD, event.delta);// 移动路径过程中进行碰撞检测if (isHit(state.currentPath as paper.Path)) {// 如果发生碰撞,则移动回原来位置moveEditPath(Operator.SUBTRACT, event.delta);}} else if (!isCreate.value && (state.hitPath?.name ===PATH_NAME.BG_IMAGE) {// 移动画布// 拖拽移动画布位置,当鼠标点击空白区域时可以移动画布位置,以及鼠标在非编辑状态下点击背景图时可以移动画布位置。myPaper?.project.layers?.forEach((item: paper.Layer) => {item.position.x += event.delta.x;item.position.y += event.delta.y;});// 画布位置移动之后,需要重新计算 startX 和 startY// 获取背景图左上角的点位坐标if (state.bgPath) {state.startX = state.bgPath.position.x - state.bgPath.bounds.width / 2;state.startY = state.bgPath.position.y - state.bgPath.bounds.height / 2;}}};
2.1,碰撞检测,判断当前路径是否发生碰撞
state.hitTestPaths 中的路径即为 state.step1Layer.children 中的路径
/*** @description: 是否发生碰撞* @param {*} path* @return {*}*/const isHit = (path: paper.Path): boolean => {// 获取背景图路径边界let bounds = state.bgPath?.bounds;if (bounds && (path.bounds.left < bounds.left || path.bounds.right > bounds.right || path.bounds.top < bounds.top || path.bounds.bottom > bounds.bottom)) {return true;}// 检测碰撞群体是否发生碰撞for (let i = 0; i < state.hitTestPaths.length; i++) {if (state.hitTestPaths[i] !== path) {const overlop = path.intersect(state.hitTestPaths[i]);overlop?.remove();if (overlop?.area > 0) {return true;}}}// 默认没发生碰撞return false;};
2.2,移动编辑状态路径
/*** @description: 移动编辑状态选框到目标位置* @param {*} operator* @param {*} point* @return {*}*/const moveEditPath = (operator: Operator, point: paper.Point) => {if (operator === Operator.ADD && state.currentPath && state.finishBtn) {// 移动选框state.currentPath.parent.position.x += point.x;state.currentPath.parent.position.y += point.y;// 移动操作按钮state.finishBtn.position.x += point.x;state.finishBtn.position.y += point.y;if (state.deleteBtn) {state.deleteBtn.position.x += point.x;state.deleteBtn.position.y += point.y;}} else if (operator === Operator.SUBTRACT && state.currentPath && state.finishBtn) {// 移动选框state.currentPath.parent.position.x -= point.x;state.currentPath.parent.position.y -= point.y;// 移动操作按钮state.finishBtn.position.x -= point.x;state.finishBtn.position.y -= point.y;if (state.deleteBtn) {state.deleteBtn.position.x -= point.x;state.deleteBtn.position.y -= point.y;}}};
2.3,缩放选框
缩放选框,其实就是拖拽移动相关顶点位置,四个顶点的序号依次为 左下:0,左上:1,右上:2,右下:3。当拖拽某一个顶点缩放选框时,其相邻的两个顶点的位置也会跟着发生变化。
/*** @description: 移动编辑状态选框顶点缩放选框大小* @param {*} operator* @param {*} point* @return {*}*/const moveEditPathSegment = (operator: Operator, point: paper.Point) => {if (operator === Operator.ADD && state.hitSegment) {state.hitSegment.point.x += point.x;state.hitSegment.point.y += point.y;// 水平方向修改顶点位置if (state.hitSegment._index % 2 == 0) {state.hitSegment.next.point.x += point.x;state.hitSegment.previous.point.y += point.y;} else {// 垂直方向修改顶点位置state.hitSegment.previous.point.x += point.x;state.hitSegment.next.point.y += point.y;}} else if (operator === Operator.SUBTRACT && state.hitSegment) {state.hitSegment.point.x -= point.x;state.hitSegment.point.y -= point.y;// 水平方向修改顶点位置if (state.hitSegment._index % 2 == 0) {state.hitSegment.next.point.x -= point.x;state.hitSegment.previous.point.y -= point.y;} else {// 垂直方向修改顶点位置state.hitSegment.previous.point.x -= point.x;state.hitSegment.next.point.y -= point.y;}}// 移动操作按钮setOperatorBtnShow(state.currentPath as paper.Path);};
3,mouseup 绘制完成设置选框为选中状态,移动并显示操作按钮
/*** @description: paper鼠标抬起事件* @param {*} event* @return {*}*/const paperMouseUp = () => {if (isCreate.value && state.currentPathStatus === CurrentPathStatus.CREATE && state.currentPath) {// 创建选框鼠标抬起事件// 显示操作按钮setOperatorBtnShow(state.currentPath as paper.Path);const group = new myPaper.Group();group.addChild(state.currentPath);// 将新增选框添加进选框图层中state.step1Layer?.addChild(group);// 删除上一次新增选框state.lastCreatePath?.remove();state.lastCreatePath = group;state.currentPath.selected = true;// 修改选中状态边框样式state.currentPath.selectedColor = infoStyle.edit.strokeColor;}};
八,操作按钮绑定事件
根据 state.currentPathStatus 当前操作路径状态的值,删除或者添加路径
1,删除按钮绑定事件
/*** @description: 点击删除按钮事件* @return {*}*/const deleteBtnClick = () => {// 如果当前是创建状态,直接删除即可if (state.currentPathStatus === CurrentPathStatus.CREATE) {state.currentPath?.parent.remove();// 修改当前状态为默认状态state.currentPathStatus = CurrentPathStatus.DEFAULT;// 隐藏操作按钮setOperatorBtnHide();// 更新新增框选样式emits('update-is-create', false);} else {// 删除选框信息emits('delete-info', state.currentPath?.data);}};
2,完成按钮绑定事件
/*** @description: 点击完成按钮事件* @return {*}*/const finishBtnClick = () => {// 如果当前是创建状态,则新建选框信息if (state.currentPathStatus === CurrentPathStatus.CREATE) {// 添加选框信息emits('add-info',state.currentPath.data);} else {// 移动选框位置emits('update-info-rect',state.currentPath.data);}};
九,响应式渲染
监听窗口尺寸变化,重置画布大小
/*** @description: 窗口大小发生改变* @return {*}*/const resizeHandle = () => {if (myPaper?.view && canvas.value) { myPaper.view.setViewSize(canvas.value?.clientWidth,canvas.value?.clientHeight);}};// 监听窗口大小改变事件window.addEventListener('resize', resizeHandle);
十,组件销毁、事件解绑。
onUnmounted(() => {if (state.deleteBtn) {state.deleteBtn.onClick = null;state.deleteBtn.onMouseMove = null;}if (state.finishBtn) {state.finishBtn.onClick = null;state.finishBtn.onMouseMove = null;}if (state.tool) {// 注册鼠标按下事件state.tool.off('onMouseDown', paperMouseDown);// 注册鼠标移动事件state.tool.off('onMouseMove', paperMouseMove);// 注册拖拽事件state.tool.off('onMouseDrag', paperMouseDrag);// 注册鼠标抬起事件state.tool.off('onMouseUp', paperMouseUp);}if (canvas.value) {canvas.value.remove();}window.removeEventListener('resize', resizeHandle);});