实现 canvas 交互(元素的事件处理)

前言

之前公司有个需求,要在用户上传的文档上添加批注,涉及到一些图形的绘制,这可以用 canvas 实现;还要求当点击某些批注时有交互效果,比如颜色进行高亮。众所周知,无论你在 <canvas> 元素上绘制多少内容,能通过 js 获取到的对象只有 canvas 节点本身,那么要如何实现对 canvas 画布上的某个元素的交互效果呢。实现方案不止一种,本文就来说说我的做法。

将图片绘制到画布

定义 CanvasAnnotatenew CanvasAnnotate() 时需传入一个 <canvas> 节点的 id canvasId,以及文档图片的地址 imgUrl,等使用时只需例如 new CanvasAnnotate('myCanvas', './imgs/1.jpg') 就能实现完整功能。

CanvasAnnotate构造器内,首先会生成个图片对象并在图片加载后调用 initStage 将图片绘制到 canvas 画布上:

typescript 复制代码
// 代码片段 1
class CanvasAnnotate {
  canvas: HTMLCanvasElement | null = null
  ctx: CanvasRenderingContext2D | null = null
  img: HTMLImageElement
  constructor(canvasId: string, imgUrl: string) {
    this.canvas = document.getElementById(canvasId) as HTMLCanvasElement ?? null
    this.ctx = this.canvas.getContext('2d')
    this.img = new Image()
    if (!this.canvas || !this.ctx) {
      console.error('Canvas 或 Canvas context 为空')
      return
    }
    this.img.onload = this.initStage.bind(this)
    this.img.onerror = () => console.error('图片加载失败')
    this.img.src = imgUrl
  }
}

调用 initStage 时需用 bind 绑定 thisCanvasAnnotate 的实例,不然 initStage 内的 this 会指向图片对象。

initStage 定义如下,如果传入的文档图片尺寸大于画布,需先计算出缩小的比例 scale。考虑到图片如果损坏,则图片的宽高会为 0,作为除数就有问题,所以用了三元运算符处理。:

typescript 复制代码
// 代码片段 2
initStage() {
  // 计算图片的缩放比例
  const scale = Math.min(
    this.canvas.width / (this.img.width !== 0 ? this.img.width : 1),
    this.canvas.height / (this.img.height !== 0 ? this.img.height : 1)
  )
  this.width = this.img.width * scale
  this.height = this.img.height * scale

  // 清除画布
  this.ctx.clearRect(0, 0, this.width, this.height)
  // 绘制图片
  this.ctx.drawImage(this.img, 0, 0, this.width, this.height)
}

定义图形类

基类

现在来处理标注图形的绘制,为了简便,我就用矩形和圆形来演示。先定义类 BaseShape 作为所有图形的基类:

typescript 复制代码
// 代码片段 3
class BaseShape {
  // 存储事件绑定
  listeners: { [key in EventEnum]?: ((e: MouseEvent) => void)[] } = {}
  constructor(
    public id: string,
    public ctx: CanvasRenderingContext2D,
    public color: string
  ) { }
  // 路径
  get path(): Path2D {
    return new Path2D()
  }
  // 绘制
  draw() {
    this.ctx.fillStyle = this.color
    this.ctx.fill(this.path)
  }
  // 添加事件绑定
  on(eventName: EventEnum, callback: (e: any) => void) {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = []
    }
    this.listeners[eventName]?.push(callback)
  }
  // 判断点是否在路径区域内
  isPointInRegion(x: number, y: number): boolean {
    return this.ctx.isPointInPath(this.path, x, y)
  }
}
  • constructor()接收 3 个所有图形都有的属性作为参数:
    • id 作为图形实例的唯一标识,用于表明是哪个图形触发了事件;
    • ctx 就是 canvas 的上下文对象;
    • color 为图形绘制时填充或描边的颜色。
  • listeners 用于存储各个图形绑定的事件,值为一个对象,对象的 key 为事件名称,比如 'click',value 为对应事件触发时要调用的回调函数数组;
  • path 是一个 Path2D 类型的值,在不同的具体图形子类中会重写,以返回不同的图形路径;
  • draw 用于给当前路径上色;
  • on 用于给图形添加事件绑定。如果某个图形需要单独绑定某个事件,就可以通过图形实例的 on 方法;
  • isPointInRegion 即为交互实现的关键 ,它通过 ctx.isPointInPath(this.path, x, y) 方法判断当前路径中是否包含指定点 (x, y)。this.path 为要检查的 Path2D 路径,这也是 path 的值需为 Path2D 类型的原因。若指定点包含在指定的路径内,会返回 true,否则返回 false

子类

具体图形类的定义如下:

typescript 复制代码
// 代码片段 4
/** 矩形 */
class Rectangle extends BaseShape {
  listeners = {
    [EventEnum.CLICK]: [
      (e: MouseEvent) => {
        const x = e.offsetX
        const y = e.offsetY
        const isInRegion = this.isPointInRegion(x, y)
        const oldColor = this.color
        const newColor = isInRegion ? 'red' : defaultColor
        if (oldColor !== newColor) {
          this.color = newColor
          this.draw()
        }
      }
    ]
  }
  constructor(
    id: string,
    ctx: CanvasRenderingContext2D,
    color: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number
  ) {
    super(id, ctx, color)
  }
  get path() {
    const rectPath = new Path2D()
    rectPath.rect(this.x, this.y, this.width, this.height)
    return rectPath
  }
}

/** 圆 */
class CircleCircle extends BaseShape {
  constructor(
    id: string,
    ctx: CanvasRenderingContext2D,
    color: string,
    public x: number,
    public y: number,
    public radius: number,
    public startAngle: number,
    public endAngle: number
  ) {
    super(id, ctx, color)
  }
  get path() {
    const rectPath = new Path2D()
    rectPath.arc(this.x, this.y, this.radius, this.startAngle, this.endAngle)
    return rectPath
  }
}

对于矩形,我这里统一绑定了 click 事件,被点击的矩形会变成红色,其它矩形则为默认色。其实就是再次对路径填充一次颜色。传给 isPointInRegionxy 的值通过鼠标事件的属性 offsetXoffsetY 获得。

绘制图形

我们有了生成矩形和圆形的类,就可以在代码片段 2 的 initStage 中,进行绘制了:

typescript 复制代码
// 代码片段 5
initStage() {
  // ...
  // 添加要绘制的图形
  const rect0 = new Rectangle('rect0', this.ctx, defaultColor, 0, 0, 100, 100)
  const rect1 = new Rectangle('rect1', this.ctx, defaultColor, 300, 0, 100, 100)
  const circle0 = new CircleCircle('circle0', this.ctx, defaultColor, 200, 400, 50, 0, 2 * Math.PI)
  this.shapes.push(rect0, rect1, circle0)
  // 绘制图形
  this.drawGraphics()
}

将矩形 rect0rect1 以及圆形 circle0 都放进 shapes 数组,在 draw 方法中循环遍历,进行填色处理:

typescript 复制代码
// 代码片段 6
drawGraphics() {
  if (this.isDrawing) return
  this.isDrawing = true
  this.shapes.forEach(shape => {
    shape.draw()
  })
  this.isDrawing = false
}

事件监听

还是在 initStage 内,我们可以针对某个图形添加单独的事件绑定,比如当点击某个图形时,打印被点击图形的 id。调用图形的 on 方法后就会往对应图形的 listeners 对象中,添加事件绑定了:

typescript 复制代码
// 代码片段 7
this.addShapeClickListener(rect0)
this.addShapeClickListener(rect1)
this.addShapeClickListener(circle0)

addShapeClickListener(shape: BaseShape) {
  shape.on(EventEnum.CLICK, (e) => {
    if (e.offsetX !== undefined && e.offsetY !== undefined) {
      const isInRegion = shape.isPointInRegion(e.offsetX, e.offsetY)
      if (isInRegion) {
        console.log(shape.id)
      }
    }
  })
}

最后就是对 canvas 添加事件监听,当事件触发时,就在回调中对存放了所有图形的 shapes 数组进行遍历,如果某个图形的 listeners 对象中有 key 为对应的事件名称,则遍历 key 的 value,即绑定的那些对应事件的回调,逐个调用:

typescript 复制代码
this.handleListeners()
handleListeners() {
  this.handleClick = (e: MouseEvent) => {
    this.shapes.forEach((shape) => {
      const listeners = shape.listeners[EventEnum.CLICK]
      if (listeners) {
        listeners.forEach((listener) => listener(e))
      }
    })
  }
  this.canvas.addEventListener(EventEnum.CLICK, this.handleClick)
}

效果展示

效果如下,点击矩形会变红,点击某个图形则打印该图形的 id:

完整代码可阅览:github/gitee

相关推荐
天天扭码19 分钟前
不需要编写代码——借助Cursor零基础爬取微博热榜(含Cursor伪免费使用教程)
前端·openai·cursor
大阳光男孩20 分钟前
uniapp的通用页面及组件基本封装
前端·javascript·uni-app
Moment32 分钟前
跨端项目被改需求逼疯?FinClip 可能是非常不错的一次选择
前端·javascript
这里有鱼汤35 分钟前
无需HTML/CSS!用Python零基础打造专业级数据应用——Streamlit入门指南
前端·后端·python
Gazer_S39 分钟前
【行业树选择器组件:基于Vue3与Element Plus的高性能树形选择组件优化与重构】
前端·javascript·重构
白羊@1 小时前
鸿蒙案例---生肖抽卡
前端·javascript·华为·harmonyos
橙某人1 小时前
🍊🍊🍊在网格中进行拖动布局-Javascript、Vue
前端·javascript·vue.js
若川1 小时前
Taro 4 已发布:11. Taro 是如何解析入口配置 app.config.ts 和页面配置的?
前端·javascript·微信小程序
八了个戒1 小时前
「数据可视化 D3系列」入门第一章:Hello D3.js
开发语言·前端·javascript·数据可视化·canvas
二川bro1 小时前
深度解析 Vue 项目 Webpack 分包与合包 一文读懂
前端·vue.js·webpack