使用canvas 如何绘制形状并支持拖拽、缩放功能

5,038 阅读10分钟

引言

   之前遇到过一个面试的机试题,就是用画布绘制形状,并且支持缩放、拖拽功能。现在有点时间就分享一下我是如何一步一步完成这个功能的。看这篇信息之前最好先去看一下canvasapicanvas API 穿梭机。

开始编写

先写出容器Dom,和样式 html

<div id="chart-wrap" class="chart-wrap"></div>

css

html,body {
  margin: 0;
  height: 100%;
  overflow: hidden;
}
.chart-wrap {
  height: calc(100% - 40px);
  margin: 20px;
  box-shadow: 0 0 3px orange;
}

首先绘制一个形状

这里写一个 名叫 chart 的类,在 构造器 constructor 里初始化画布,写好绘制形状的函数、以及画布渲染。代码如下:

class chart {
  // 初始构造器
  constructor(params) {
    var wrapDomStyle = getComputedStyle(params.el);
    this.width = parseInt(wrapDomStyle.width, 10);
    this.height = parseInt(wrapDomStyle.height, 10);

    // 创建canvas画布
    this.El = document.createElement('canvas');
    this.El.height = this.height;
    this.El.width = this.width;
    this.ctx = this.El.getContext('2d');

    params.el.appendChild(this.El);
  }

  // 绘制圆形
  drawCircle(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.arc(data.x, data.y, data.r, 0, 2 * Math.PI);
    this.ctx.fill();
  }

  // 添加形状
  push(data) {
    this.drawCircle(data);
  }
}

// 构建图表对象
var chartObj = new chart( { el: document.getElementById('chart-wrap') } );

// 绘制圆形
chartObj.push({
  fillStyle: 'pink',
  x: 400,
  y: 300,
  r: 50
});

上面代码结构很简单,new 一个对象,传入容器Dom,在constructor 中初始化一个画布放入 div#chart-wrap 这个 dom 中,再把创建好的实例赋值给 chartObj 这个变量。

通过调用类的 push 方法,绘制一个圆形。

代码效果点击此处观看

绘制多个、多种类型形状

如果想绘制其他图形就需要加 type 判断,以上代码改造完成后如下:

class chart {
  // 初始构造器
  constructor(params) {
    var wrapDomStyle = getComputedStyle(params.el);
    this.width = parseInt(wrapDomStyle.width, 10);
    this.height = parseInt(wrapDomStyle.height, 10);

    // 创建canvas画布
    this.El = document.createElement('canvas');
    this.El.height = this.height;
    this.El.width = this.width;
    this.ctx = this.El.getContext('2d');

    params.el.appendChild(this.El);
  }

  // 绘制圆形
  drawCircle(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.arc(data.x, data.y, data.r, 0, 2 * Math.PI);
    this.ctx.fill();
  }
  
  // _____________ 添加绘制线条方法 ____________
  drawLine(data) {
    var arr = data.data.concat()
    var ctx = ctx || this.ctx;  

    ctx.beginPath()
    ctx.moveTo(arr.shift(), arr.shift())
    ctx.lineWidth = data.lineWidth || 1
    do{
      ctx.lineTo(arr.shift(), arr.shift());
    } while (arr.length)

    ctx.stroke();
  }
  
  // ___________ 添加绘制矩形方法 ______________
  drawRect(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.fillRect(...data.data);
  }

  // ___________ 添加一个判断类型绘制的方法 _____________
  draw(item) {
    switch(item.type){
      case 'line':
        this.drawLine(item)
        break;
      case 'rect':
        this.drawRect(item)
        break;
      case 'circle':
        this.drawCircle(item)
        break;
    }
  }
  
  // 添加形状
  push(data) {
    this.draw(data); // ____________ 修改调用绘制方法 ____________
  }
}

// 构建图表对象
var chartObj = new chart( { el: document.getElementById('chart-wrap') } );

// 绘制圆形
chartObj.push({
  type: 'circle', // ____________ 这里添加了一个类型 __________________
  fillStyle: 'pink',
  x: 400,
  y: 300,
  r: 50
});

// ___________ 添加绘制线条 __________
chartObj.push({
  type: 'line',
  lineWidth: 4,
  data: [100, 90, 200, 90, 250, 200, 400, 200]
})

// ___________ 添加绘制矩形 __________
chartObj.push({
  type: 'rect',
  fillStyle: "#0f00ff",
  data: [350, 400, 100, 100]
})

对比前面这里添加了一个绘制矩形(drawRect)、绘制线条(drawLine)的方法 和 数据,并且添加了判断渲染类型的函数(draw)。

代码效果点击此处观看

添加缩放功能

添加缩放需要先理清一些东西。

缩放 canvas 提供了两个类型方法可以实现,一个是在当前缩放基础上缩放,一个是在基础画布上缩放。

矩阵变化不只有缩放,但是可以其他参数不变只更改缩放值

当前缩放基础上缩放:scale()缩放当前绘图至更大或更小,transform()替换绘图的当前转换矩阵;

  意思就是原本画布大小是 1,第一次放大 2倍,就变成2,第二次放大2倍就变成4

在基础画布上缩放: setTransform()将当前转换重置为单位矩阵。然后运行 transform()。

  意思就是原本画布大小是 1,第一次放大 2倍,就变成2,第二次放大2倍还是2,因为重置回原来的1后再放大的

这里我使用 setTransform() 缩放画布

第一步

因为要缩放所以必须保存好当前的缩放值,就在constructor 加以下参数,以及在 push() 方法下保存数据、render() 重绘所有数据

constructor() {
  // 因为canvas是基于状态绘制的,也就是设置了缩放值,再绘制的元素才会根据缩放倍数绘制,因此需要把每个绘制的对象保存起来。
  this.data = []; 
  this.scale = 1; // 默认缩放值是 1
}

// 添加形状
push(data) {
  // push 方法中添加保存数据操作
  this.data.push(data);
}

// 渲染整个 图形画布
render() {
  this.El.width = this.width

  this.data.forEach(item => {
    this.draw(item)
  })
}

第二步

因为缩放时鼠标滚轮控制,所以加上监听滚轮事件,而且是在鼠标移入画布中时才添加,不在画布中就不需要监听滚轮事件。

constructor() {
  // 添加滚轮判断事件
  this.addScaleFunc();
}
 
// 添加缩放功能,判断时机注册移除MouseWhell事件
addScaleFunc() {
  this.El.addEventListener('mouseenter', this.addMouseWhell);
  this.El.addEventListener('mouseleave', this.removeMouseWhell);
}

// 添加 mousewhell 事件
addMouseWhell = () => {
  document.addEventListener('mousewheel', this.scrollFunc, {passive: false});
}

// 移除mousewhell 事件
removeMouseWhell = () => {
  document.removeEventListener('mousewheel', this.scrollFunc, {passive: false});
}

第三步

滚轮事件监听完成后,就是调用具体的缩放实现代码了

constructor() {
  // 缩放具体实现会用到的数据
  this.maxScale = 3; // 最大缩放值
  this.minScale = 1; // 最小缩放值
  this.step = 0.1;   // 缩放率
  this.offsetX = 0;  // 画布X轴偏移值
  this.offsetY = 0;  // 画布Y轴偏移值
}

// 缩放 具体计算
scrollFunc = (e) => {
  // 阻止默认事件 (缩放时外部容器禁止滚动)
  e.preventDefault();

  if(e.wheelDelta){
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0){
      this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
      this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY

      this.scale += this.step
    } else {
      this.offsetX += this.scale <= this.minScale ? 0 : offsetX
      this.offsetY += this.scale <= this.minScale ? 0 : offsetY

      this.scale -= this.step
    }

    this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
    
    this.render()
  }
}

// 在类型判断渲染方法内添加设置缩放
draw() {
  this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);
}

以上代码效果预览

解释:

   第一步骤第二步骤理解起来很容易,比较麻烦的是第三步骤,下面就来详细解释一下第三部具体缩放实现。

缩减一下代码

scrollFunc = (e) => {
  // 阻止默认事件 (缩放时外部容器禁止滚动)
  e.preventDefault();

  if(e.wheelDelta){
  
    e.wheelDelta > 0 ? this.scale += this.step : this.scale -= this.step
    
    this.render()
  }
}

只需要上述几行就实现了缩放。判断 e.wheelDelta 是向上滚动还是向下,从而增加或减少 this.scale 的大小,最后调用 render() 重新绘制当前画布。

e.preventDefault() 就不多解释了,大家都知道是解决默认行为的。但是有一点要解释一下 在调用 scrollFunc() 这个函数的事件监听器的第三个参数 {passive: false} 是必须加的(默认就是 {passive: true}),不然无法阻止默认的滚动事件。

大家可以在演示例子中注释掉 scrollFunc 中的其它代码查看效果,发现缩放是可以了,但是,却没有根据鼠标位置进行缩放,而是始终以画布(0,0) 的位置缩放。所以画布放大后会向右下偏移,因此需要向左和上偏移校正,使缩放看起来就像在鼠标位置缩放。

在上方代码上改造一下 代码如下:

scrollFunc = (e) => {
  // 阻止默认事件 (缩放时外部容器禁止滚动)
  e.preventDefault();

  if(e.wheelDelta){
  
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0){
      this.offsetX -= offsetX
      this.offsetY -= offsetY

      this.scale += this.step
    } else {
      this.offsetX += offsetX
      this.offsetY += offsetY

      this.scale -= this.step
    }
    
    this.render()
  }
}

x,y 是鼠标距离画布原始原点的距离,offsetX,offsetY 是本次缩放的偏移量,然后判断放大或者缩小从而增减整体画布的偏移量。

本次偏移量计算方式:鼠标距原始点距离(x,y) 除以 缩放值 this.scale 再乘以 缩放率 this.step

  解释:因为是使用setTransform(),所以每次放大或者缩小都是在原始画布大小的基础上缩放,所以需要除以缩放值,找到在原始缩放基础上鼠标距离原始点的距离。

  解释:如果使用scale(),就不需要除以缩放值,直接当前缩放值乘以缩放率就能等于现在实际缩放值

最后再把缩放功能完善,添加最大缩放值this.maxScale 和 最小缩放值 this.minScale 限制,完成代码如下:

// 缩放 具体计算
scrollFunc = (e) => {
  // 阻止默认事件 (缩放时外部容器禁止滚动)
  e.preventDefault();

  if(e.wheelDelta){
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0){
      this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
      this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY

      this.scale += this.step
    } else {
      this.offsetX += this.scale <= this.minScale ? 0 : offsetX
      this.offsetY += this.scale <= this.minScale ? 0 : offsetY

      this.scale -= this.step
    }

    this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
    
    this.render()
  }
}

以上缩放值计算就完成了,最后只需调用 this.render(),在this.render 中会调用 this.draw 函数,这个函数里调用setTransform 方法,这里会将更改后的缩放值,以及偏移值设置到画布中。

this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);

添加拖拽画布的效果

首先理清一下拖拽的步骤 鼠标按下 => 鼠标移动 => 鼠标放开

鼠标按下:我们用 mousedown 事件,然后在按下事件中注册 鼠标移动 事件

鼠标移动:我们用 mousemove 事件,在鼠标移动事件中 具体实现画布移动

鼠标放开:我们用 mouseup 事件,在鼠标放开事件中 删除 鼠标移动 事件

具体代码如下:

constructor(params) {
  this.wrapDom = params.el;
  this.addDragFunc();
}

// 添加拖拽功能,判断时机注册移除 拖拽 功能
addDragFunc() {
  this.El.addEventListener('mousedown', this.addMouseMove);
  document.addEventListener('mouseup', this.removeMouseMove);
}

// 添加鼠标移动 功能,获取保存当前点击坐标
addMouseMove = (e) => {
  this.targetX = e.offsetX
  this.targetY = e.offsetY

  this.mousedownOriginX = this.offsetX;
  this.mousedownOriginY = this.offsetY;
  
  this.wrapDom.style.cursor = 'grabbing'
  this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
}
// 移除鼠标移动事件
removeMouseMove = () => {
  this.wrapDom.style.cursor = ''
  this.El.removeEventListener('mousemove', this.moveCanvasFunc, false)
  this.El.removeEventListener('mousemove', this.moveShapeFunc, false)
}

// 移动画布
moveCanvasFunc = (e) => {
  // 获取 最大可移动宽
  var maxMoveX = this.El.width / 2;
  var maxMoveY = this.El.height / 2;

  var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
  var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);

  this.offsetX = Math.abs(offsetX) > maxMoveX ? this.offsetX : offsetX
  this.offsetY = Math.abs(offsetY) > maxMoveY ? this.offsetY : offsetY
  
  this.render()
}

以上代码效果演示

其它代码都很简单,这里就详细解释一下 addMouseMove()moveCanvasFunc() 做了哪些操作。

addMouseMove 函数中 使用 targetX,targetY 保存了鼠标点击时的坐标,mousedownOriginX ,mousedownOriginX 保存了鼠标点击时 画布的整体偏移量。

再在 moveCanvasFunc 函数中 计算出移动后的整体偏移量,moveCanvasFunc 函数中的代码可以简化成这样:

moveCanvasFunc = (e) => {
  var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
  var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
  
  this.render()
}

其他代码是为了限制偏移量的最大值,最后调用this.render()

整体来讲,拖拽画布功能比缩放稍微简单一些,同样这里最后会调用 this.render(),在this.render 中会调用 this.draw 函数,这个函数里调用了setTransform 方法,这里会将更改后的缩放值,以及偏移值设置到画布中。

this.ctx.setTransform(this.scale,0, 0, this.scale, this.offsetX, this.offsetY);

拖拽画布中的形状

如果要拖拽画布中的形状,需要判断鼠标点击的位置是否处于形状中,而且因为层级关系,只能控制顶层的形状。

因此需要写鼠标按下时是否处于形状内部的判断方法,这里我们只写了矩形、圆形、线段的判断方法。

因为之前已经在实现画布拖拽的时候,实现了拖拽功能,现在只需要要改造 addMouseMove 函数 和添加 形状移动 函数,以及三个判断方法。

整体代码如下:

// 添加鼠标移动 功能,获取保存当前点击坐标
addMouseMove = (e) => {

  this.targetX = e.offsetX
  this.targetY = e.offsetY

  this.mousedownOriginX = this.offsetX;
  this.mousedownOriginY = this.offsetY;

  var x = (this.targetX - this.offsetX) / this.scale;
  var y = (this.targetY - this.offsetY) / this.scale;

  this.activeShape = null

  this.data.forEach(item => {
    switch(item.type){
      case 'rect':
        this.isInnerRect(...item.data, x, y) && (this.activeShape = item)
        break;
      case 'circle':
        this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
        break;
      case 'line':
        var lineNumber = item.data.length / 2 - 1
        var flag = false
        for(let i = 0; i < lineNumber; i++){
          let index = i*2;
          flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
          if(flag){
            this.activeShape = item
            break;
          }
        }
    }
  })

  if(!this.activeShape){
    this.wrapDom.style.cursor = 'grabbing'
    this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
  } else {
    this.wrapDom.style.cursor = 'all-scroll'
    this.shapedOldX = null
    this.shapedOldY = null
    this.El.addEventListener('mousemove', this.moveShapeFunc, false)
  }
}

// 移动形状
moveShapeFunc = (e) => {
  var moveX = e.offsetX - (this.shapedOldX || this.targetX);
  var moveY = e.offsetY - (this.shapedOldY || this.targetY);
  
  moveX /= this.scale
  moveY /= this.scale

  switch(this.activeShape.type){
    case 'rect':
      let x = this.activeShape.data[0]
      let y = this.activeShape.data[1]
      let width = this.activeShape.data[2]
      let height = this.activeShape.data[3]
      this.activeShape.data = [x + moveX, y + moveY, width, height]
      break;
    case 'circle':
      this.activeShape.x += moveX
      this.activeShape.y += moveY
      break;
    case 'line':
      var item = this.activeShape;
      var lineNumber = item.data.length / 2
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        item.data[index] += moveX
        item.data[index + 1] += moveY
      }
  }
  this.shapedOldX = e.offsetX
  this.shapedOldY = e.offsetY

  this.render()
}

// 判断是否在矩形框内
isInnerRect(x0, y0, width, height, x, y) {
  return x0 <= x && y0 <= y && (x0 + width) >= x && (y0 + height) >= y
}

// 判断是否在圆形内
isInnerCircle(x0, y0, r, x, y) {
  return Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2) <= Math.pow(r, 2)
}

// 判断是否在路径上
isInnerPath(x0, y0, x1, y1, x, y, lineWidth) {
  var a1pow = Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2);
  var a1 = Math.sqrt(a1pow, 2)
  var a2pow = Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)
  var a2 = Math.sqrt(a2pow, 2)

  var a3pow = Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2)
  var a3 = Math.sqrt(a3pow, 2)

  var r = lineWidth / 2
  var ab = (a1pow - a2pow + a3pow) / (2 * a3)var ab = (a1pow - a2pow + a3pow) / (2 * a3)
  var h = Math.sqrt(a1pow - Math.pow(ab, 2), 2)

  var ad = Math.sqrt(Math.pow(a3, 2) + Math.pow(r, 2))

  return h <= r && a1 <= ad && a2 <= ad
}

以上代码效果演示

以上代码在 addMouseMove 中加入了判断是否处于形状内部的操作。

var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;

this.activeShape = null

this.data.forEach(item => {
  switch(item.type){
    case 'rect':
      this.isInnerRect(...item.data, x, y) && (this.activeShape = item)
      break;
    case 'circle':
      this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
      break;
    case 'line':
      var lineNumber = item.data.length / 2 - 1
      var flag = false
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
        if(flag){
          this.activeShape = item
          break;
        }
      }
  }
})

根据鼠标位置获取到基于原始缩放状态下距离画布原点的x,y 坐标,根据不同 type 调用不同方法判断是否处于当前形状中。

然后根据是否处于形状内部判断注册 拖拽画布 还是 拖拽形状 的事件

if(!this.activeShape){
  this.wrapDom.style.cursor = 'grabbing'
  this.El.addEventListener('mousemove', this.moveCanvasFunc, false)
} else {
  this.wrapDom.style.cursor = 'all-scroll'
  this.shapedOldX = null
  this.shapedOldY = null
  this.El.addEventListener('mousemove', this.moveShapeFunc, false)
}

如果处于形状内部,就修改形状位置参数,并调用 this.render(),重新渲染画布

// 移动形状
moveShapeFunc = (e) => {
  var moveX = e.offsetX - (this.shapedOldX || this.targetX);
  var moveY = e.offsetY - (this.shapedOldY || this.targetY);
  
  moveX /= this.scale
  moveY /= this.scale

  switch(this.activeShape.type){
    case 'rect':
      let x = this.activeShape.data[0]
      let y = this.activeShape.data[1]
      let width = this.activeShape.data[2]
      let height = this.activeShape.data[3]
      this.activeShape.data = [x + moveX, y + moveY, width, height]
      break;
    case 'circle':
      this.activeShape.x += moveX
      this.activeShape.y += moveY
      break;
    case 'line':
      var item = this.activeShape;
      var lineNumber = item.data.length / 2
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        item.data[index] += moveX
        item.data[index + 1] += moveY
      }
  }
  this.shapedOldX = e.offsetX
  this.shapedOldY = e.offsetY

  this.render()
}

移动形状同样也是要获取到基于原始缩放大小(可以看到上方除了this.scale)的画布的移动量 moveX,moveY,再将移动量增加至 选中形状的位置坐标中。

保存好当前偏移量 this.shapedOldX,this.shapedOldY,供下次事件触发使用。

判断是否处于形状内部方法解释

1.判断是否处于矩形框内 根据当前计算出的 x,y 坐标,判断是否小于 矩形的x,y 坐标,并且判断是否大于矩形 (x + width)(y + height) 的右下角坐标。

2.判断是否处于圆形内 根据当前计算出的 x,y 坐标,计算出距离圆心 坐标的距离,如果小于等于圆的半径,就说明处于圆形内部。

3.判断是否处于线段中 假设线段 AB(线段粗为90),鼠标点击点为C,判断AC 或 BC 是否大于 AD,如果大于,C肯定不处于线段内,并且C与AB 的垂直距离CH必须小于等于 线段宽度的一半。

在这里插入图片描述

这里只支持单个线段判断,多个连接线段判断不精确,连接处会有多余部分无法判断。 如下图:

在这里插入图片描述

这是宽度为90的线段,红色区域上述方法能判断,箭头指向部分无法判断。

这里暂时不考虑也是因为如果 线段之间的夹角小于 90deg,默认形状会是:

在这里插入图片描述

可以看 miterLimit 属性lineJoin 属性 以及 lineCap 属性,这些属性对线段影响较大,这里只做默认状态下单条线段判断演示。

总结

OK,以上就已经把最开始讲的需求做完了,有兴趣的朋友可以更改Demo 中的例子修改参数看看效果。

以上如有问题或疏漏,欢迎指正,谢谢。