likes
comments
collection
share

Canavs中图形选中解决方案

作者站长头像
站长
· 阅读数 31

一、引言

Canvas是一种图像渲染技术,绘制在canvas上的图形不是像HTML元素那样是分开的元素,图形绘制到canvas上后,就会和canvas形成一个整体。因此我们无法像获取某个DOM元素那样选中已经绘制在Canvas画布上的指定图形。

我们希望绘制在canvas画布上的图形能被用户选中,并且用户可以对选中的图形做平移、旋转、缩放等操作。如下示例图,是fabric.js的一个示例,fabric.js是一个基于canvas的库,它就基于canavs实现了图形的选中,对图形的一系列操作等功能。

Canavs中图形选中解决方案

本篇内容,我们要学习如何实现选中canvas上指定的图形。经过Canvas基础篇的学习,可以知道Canvas几乎所有的绘制都和坐标点有关,我们可以利用这一点,换一个角度来实现图形选中的功能。

二、实现方案

因为Canvas几乎所有的绘制都和坐标点有关,那么我们不妨利用它来判断当前鼠标点击的位置是否在某个图形的区域范围内来标识图形是否被选中。

1、isPointInPath()

CanvasRenderingContext2D对象提供了isPointInPath()方法判断当前路径中是否包含指定的坐标点。如果指定坐标点包含在当前路径范围则返回true,否则返回false。

它的语法如下:

ctx.isPointInPath(x,y)
// 或 
ctx.isPointInPath(path,x,y)

参数xy分别是是将要检测的点的x轴坐标和y轴坐标。而参数path是指定的路径,判断检测点是不是包含在这个path路径范围内。

const rect = ctx.rect(0,0,100,100)
ctx.fill()
console.log(ctx.isPointInPath(50,50))		// true

如上当我们绘制了一条路径,在未开启一条新的路径前用isPointInPath()方法检测指定的点是否在路径范围内是可以的。但是当我们再开启了一条新的路径,用这个方法判断就和我们预想的不同了。

const rect = ctx.rect(0,0,100,100)
ctx.fill()
// 开启新路径
ctx.beginPath()
ctx.rect = ctx.rect(100,0,100,100)
ctx.fill()
console.log(ctx.isPointInPath(50,50))		// false

因为isPointInPath()方法检测的是是否包含在当前路径下,所以当我们开启一条新的路径,它检测的是坐标点在不在这条新路径范围内,但理论上来说,只要我的点所处的位置上存在图形,就应该选中这个图形。所以isPointInPath()方法并不适合用作选中图形的实现方案。

2、自定义计算方式

Canvas自带的方法不是最优的方式,那可以自定义实现位置的计算来判断当前点是否包含于canvas上任意图形的区域。

方案思路以矩形为例,矩形的范围计算是最简单易懂的。首先可以对绘制矩形的方法进行封装,将它封装成一个类RectangleRectangle主要包含的方法是绘制方法draw()和判断监测点是否在范围内isPointInPath()方法。

绘制方法需要的参数可以通过实例化的时候传入,然后按照canvas绘制矩形的API绘制就行了。而isPointInPath()方法需要接收两个参数,即被检测点的x轴和y轴坐标。再用计算公式判断是否在矩形区域内。

Canavs中图形选中解决方案

三、具体实现

1、单个图形选中位移

首先先从简单的开始,先只绘制一个图形,然后鼠标在画布上点击,判断当前点击的位置是否在这个图形上,如果在那么移动鼠标时这个图形随着鼠标的移动发生位移。

首先我们要定义矩形类Rectangle,并且实现绘制方法和检测点是否在区域内的方法。

class Rectangle{
  constructor(options){
    this.type = 'rectangle'
    this.x = options.x || 0
    this.y = options.y || 0
    this.width = options.width || 100
    this.height = options.height || 100
    this.fillStyle = options.fillStyle || 'rgb(0,0,0)'
  }
  draw(ctx){
    const {x,y,width,height,fillStyle} = this
    ctx.beginPath()
    ctx.rect(x,y,width,height)
    ctx.fillStyle = fillStyle
    ctx.fill()
  }
  isPointInPath(pointX,pointY){
    const { x, y, width, height } = this
    return pointX > x && pointX < x+width && pointY > y && pointY < y+height
  }
}

Rectangle类的构造函数会接收一个对象类型的参数,各绘制属性都给定一个默认值。接下来用这个矩形类在画布上绘制出矩形,这里使用到关键帧函数,是为了后续做位移用。

const rect = new Rectangle({
  x:100,
  y:100
})

function drawShape(){
  ctx.clearRect(0,0,cvs.width,cvs.height)
  rect.draw(ctx)
}

function animationFrame(){
  window.requestAnimationFrame(()=>{
    drawShape()
    animationFrame()
  })
}
animationFrame()

给画布添加mousedown事件,当鼠标在画布上点击,判断当前点击的位置是否处于图形区域范围,如果在则要记录当前点击的点处于图形的什么位置,因此Rectangle类中还需定义一个方法修改鼠标点在图形内的位置。

class Rectangle{
  constructor(options){
    this.offsetX = this.offsetY = 0
  }
  ...
	pointOffset(pointX,pointY){
    const { offsetX, offsetY } = this
    this.x = pointX - x
    this.y = pointY - y
  }
  ...
}

在画布中触发mousedown事件

let activeShape = null
cvs.addEventListener('mousedown',(event)=>{
  const x = event.offsetX
  const y = event.offsetY
  const isPoint = rect.isPointInPath(x,y)
  activeShape = isPoint ? rect : null
  if(activeShape){
    activeShape.pointOffset(x,y)
  }
})

在画布中触发mousemove事件,如果当前有选中的图形则执行图形位移的方法

cvs.addEventListener('mousemove',(event)=>{
  if(activeShape){
    const x = event.offsetX
    const y = event.offsetY
    activeShape.setPosition(x,y)
  }
})

setPosition()方法也需要在类中定义

setPosition(posX,posY){
  const { offsetX, offsetY } = this
  this.x = posX - offsetX
  this.y = posY - offsetY
}

如此,就实现了在画布上选中图形,并且操作图形的功能。以上针对的是画布上指定的单个图形,那如果画布上有多个图形,怎么确定选中的是哪个图形。

2、多图形确定选中位移

多图形里面点击一个确定选中除了范围之外,还需要考虑一个层级的问题。例如,如果两个图形之间有交集,而鼠标点击的点正好在这个交集范围内,那么怎么确定选中的是哪个呢?

Canavs中图形选中解决方案

需要在图形类上再加上zIndex属性,这个属性表示图形的层级关系,当选中某一个图形的时候,找到所有图形中zIndex最大的值,并将选中的图形在最大值的基础上加1。

class Rectangle{
  constructor(options){
    this.zIndex = options.zIndex || 0
  }
}

绘制多个图形,我们需要维护一个图形数组shapeList,这个图形数组用于保存所有的图形实例。在画布上绘制图形,需要遍历这个图形数组,将绘制里面所有的图形实例。

let shapeList = [
  new Rectangle({
    x:100,
    y:100
  }),
  new Rectangle({
    x:150,
    y:150,
    fillStyle:'red'
  })
]

function drawShape(){
  ctx.clearRect(0,0,cvs.width,cvs.height)
  shapeList.forEach(shape => {
    shape.draw(ctx)
  })
}

首先需要获取到图形区域包含鼠标点击位置的所有图形,定义空数组存储

cvs.addEventListener('mousedown',(event)=>{
  // 空数组存储图形区域包含鼠标点击位置的所有图形
  let pointList = []
  shapeList.forEach(shape => {
    const isPoint = shape.isPointInPath(x,y)
    isPoint&&pointList.push(shape)
  })
})

点击图形,将该图形置于所有图形的最上层。canvas绘制图形,越晚绘制的图形,它的显示的层级越高,因此只需要将当前选中的图形zIndex属性层级给到所有图形中的最大,再根据层级大小对图形数组由低到高进行排序即可。

cvs.addEventListener('mousedown',(event)=>{
  let pointList = []
  shapeList.forEach(shape => {
    const isPoint = shape.isPointInPath(x,y)
    isPoint&&pointList.push(shape)
  })
  if(pointList.length){
    // 取到最上层的图形
    activeShape = pointList[0]
    // 将选中的图形的zIndex 在最大的zIndex基础上加1
    activeShape.zIndex = Math.max(...shapeList.map(shape => shape.zIndex)) + 1
    // 对图形列表重新排序,zIndex越小的图形,数组索引越小
    shapeList.sort((a, b) => a.zIndex - b.zIndex)
  }
})

但是这样的话,当图形已经处于最上层了,再点击,它下层的图形又变成最上层了。我们希望当点击到的图形已经处于最上层,它还是处于最上层。

Canavs中图形选中解决方案

因此需要从pointList数组中取zIndex最大的那个图形作为当前选中的图形,对pointList进行从大到小排序,依然取数组的第一个,并且调用矩形类的pointOffset()方法。

if(pointList.length){
  // 对选中的图形排序,zIndex越大的图形,数组索引越小,目的是取到最上层的图形
  pointList.sort((a,b) => b.zIndex - a.zIndex)
  // 取到最上层的图形
  activeShape = pointList[0]
  // 将选中的图形的zIndex 在最大的zIndex基础上加1
  activeShape.zIndex = Math.max(...shapeList.map(shape => shape.zIndex)) + 1
  // 对图形列表重新排序,zIndex越小的图形,数组索引越小
  shapeList.sort((a, b) => a.zIndex - b.zIndex)
  activeShape.pointOffset(x,y)
}

鼠标移动事件不需要改动,依然和上面一样调用setPosition()方法,看最终效果

Canavs中图形选中解决方案