前言
在业务中需要一种体现物体在平面图上的位置关系视图,比如IBMS中设备在楼层平面中的位置,同时可能有文字描述信息,实现这个最开始是dom加拖拽实现,但频繁操作dom太影响性能,所以使用Canvas重写这个功能
这篇文章会涉及到知识点
- canvas如何绘制图片与文字
- 文字与图片点击命中检测
- 拖拽的实现
- 解决频繁clearRect 图片出现的闪动问题
以Vue2为例
在此组件中接受要绘制要素的rect类型的列表,参数包括xy轴距离、宽高、旋转、缩放、层级等等,其他的看个人需求
<template>
<canvas
id="CDrag-canvas"
/>
</template>
<script>
//rect: {
// left: 'left', // 距canvas原点X轴距离
// top: 'top', // 距canvas原点Y轴距离
// rotate: 'rotate', // 旋转角度
// width: 'width', // 宽度(图片才有)
// height: 'height', // 高度(图片才有)
// scale: 'scale', // 缩放(图片才有)
// zIndex: 'zIndex', // 层级(图片才有)
// img: 'img', // 图片地址 (图片才有)
// text: 'text', // 文字内容 (文字才有)
// BColor: 'BColor', // 背景颜色
// color: 'color', // 文字颜色 (文字才有)
// size: 'size', // 文字大小 (文字才有)
//}
let ctx = null
let canvas = null
let select = null
export default {
props: {
// 绘制数据列表
rects: {
type: Array,
default: () => []
},
},
}
</script>
绘制图片
使用drawImage依次传入图片,距原点的距离、图片宽高
drawImage不能直接使用图片地址需要 new Image() 转为图片才行
记得在onload中绘制哦
drawImg (drawData) {
if (ctx) {
ctx.beginPath()
ctx.fillStyle = drawData.BColor || 'transparent'
const image = new Image() // 创建 img 元素
image.onload = function () {
// 执行 drawImage 语句
ctx.drawImage(image, drawData.left, drawData.top, drawData.width, drawData.height)
}
image.src = drawData.img // 设置图片源地址
}
},
绘制文字
绘制文字使用fillText 传入距原点的距离,这里还可以限制文字的最大宽度,但我们不做限制
drawText (drawData) {
if (ctx) {
ctx.beginPath()
ctx.textBaseline = 'top'
ctx.font = `${drawData.size}px system-ui`
// 设置文字宽高
drawData.width = drawData.text?.length * drawData.size
drawData.height = drawData.size
ctx.fillStyle = drawData.color
ctx.fillText(drawData.text, drawData.left, drawData.top)
}
},
切记要设置ctx.textBaseline = ‘top’ 否则文字会不在实际的的矩形范围内,因为默认是bottom看下图,红线以下其实才是内容区,所以要改为top对齐,不然后续的点击命中你就会发现根本检测不到文字
注意字体其实没有给他传宽和高,但为什么我这里有去设置了宽高了,同样是为了后续的点击命中检测。
这里高度使用字体size即可,而宽度得根据文本长度而定,一开始使用measureText() 获取文本宽度,但实际宽度远超过预测宽度,如下黑色背景为 measureText() 预测宽度,在后续命中检测中点击波区域根本检测不到,所以我使用text.length * size计算宽度效果如图二
嗯~ 基本囊括整个文本,真是机智如我
拖拽
拖拽逻辑如下
- 监听mousedown鼠标按下
- 根据点击坐标做图形命中检测
- 命中图形后在mousemove中不断更新rect的left和top
- 清空canvas后再重绘
<template>
<canvas
id="CDrag-canvas"
@mousedown="handleMousedown"
/>
</template>
// 鼠标按下
handleMousedown (event) {
// 记录点击坐标
const { clientX: downX, clientY: downY } = event
// 这里做命中检测
const result = this.getHitTestGraphic(event) || {}
const toDragX = result ? downX - result.left : 0 // 点击点到rect左边距离
const toDragY = result ? downY - result.top : 0 // 点击点到rect右边距离
const handleMousemove = ({ clientX, clientY }) => {
if (result.left) {
result.left = clientX - toDragX // 实际移动距离
result.top = clientY - toDragY // 实际移动距离
this.draw(this.rects) // 重绘方法
}
}
const handleMouseup = () => {
document.removeEventListener('mousemove', handleMousemove)
document.removeEventListener('mouseup', handleMouseup)
select = null
}
document.addEventListener('mousemove', handleMousemove)
document.addEventListener('mouseup', handleMouseup)
},
看看效果,怎么图片一闪一闪的?
因为图片是加载回来的,每次重绘时 可能图片都没加载完,导致渲染空白,在高频率的重绘时就形成闪动了,
解决图片闪动
网上有一些方法,比如使用双缓存创建副canvas,一个负责绘制,绘制完成后在给另一个canvas展示。
但我不用这个方法,既然第一次已经加载完了,第二次是不是就可以不用再重新加载呢?
第一次加载后把image存起来
// 核心代码
// 有image对象直接绘制,不走onload逻辑
if (drawData.image) {
ctx.drawImage(drawData.image, drawData.left, drawData.top, drawData.width, drawData.height)
} else {
const image = new Image() // 创建 img 元素
drawData.image = image // 第一次加载后存起来
image.onload = function () {
// 执行 drawImage 语句
ctx.drawImage(image, drawData.left, drawData.top, drawData.width, drawData.height)
}
image.src = drawData.img // 设置图片源地址
}
再来看看效果 完美!
命中检测方法
handleMousedown 时调用 getHitTestGraphic传入点击event
遍历整个rects列表,计算图片或者文字矩形的四个顶点,再使用hitTest判断当前图形是否被点击命中,命中则返回rect,接着在mousemove中不断更新left和top实现拖拽
网上也有其他命中检测方法,比如mousedown时清空画布再重绘,每绘制一个图形就使用 isPointInPath() 判断当前绘制图形是否命中。
但本着能少重绘就少重绘原则,选择了计算矩形范围做命中的方法,这也是之前为什么给文字添加宽高的原因,因为得计算它的矩形坐标
/**
* 获取命中图形
* @param { PointEvent} event
*/
getHitTestGraphic ({ clientX, clientY }) {
const result = []
this.rects.forEach(rect => {
const leftTop = [rect.left, rect.top] // 左上顶点
const rightTop = [rect.left + rect.width, rect.top] // 右上顶点
const rightBottom = [rect.left + rect.width, rect.top + rect.height] // 右下顶点
const leftBottom = [rect.left, rect.top + rect.height] // 左下顶点
if (hitTest([clientX - canvas.offsetLeft, clientY - canvas.offsetTop], [leftTop, rightTop, leftBottom, rightBottom])) {
result.push(rect)
}
})
select = result.pop()
return select
},
/**
* 判断点是否在要素范围内
* @param {*} point 点击位置
* @param {*} rect 矩形的四个顶点坐标
* @returns
*/
export function hitTest ([downX, downY], [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) {
const v1 = [x1 - downX, y1 - downY]
const v2 = [x2 - downX, y2 - downY]
const v3 = [x3 - downX, y3 - downY]
const v4 = [x4 - downX, y4 - downY]
if (
(v1[0] * v2[1] - v2[0] * v1[1]) > 0 &&
(v2[0] * v4[1] - v4[0] * v2[1]) > 0 &&
(v4[0] * v3[1] - v3[0] * v4[1]) > 0 &&
(v3[0] * v1[1] - v1[0] * v3[1]) > 0
) {
return true
}
return false
}
这个canvas系列第一篇,后续还会实现缩放、旋转等,敬请期待